fix: better error handling for api calls

This commit is contained in:
Leon
2025-07-17 10:24:21 +02:00
parent cc9e2b2f1b
commit e915330a78
2 changed files with 210 additions and 89 deletions

View File

@@ -7,26 +7,37 @@ import {
updateSettings, updateSettings,
getImapFolders, getImapFolders,
testImapConnection, testImapConnection,
processEmails,
getFeedUrl, getFeedUrl,
NewsletterCreate, NewsletterCreate,
NewsletterUpdate, NewsletterUpdate,
SettingsCreate, SettingsCreate,
} from "../api" } from "../api"
import { toast } from "sonner"
// Mock the global fetch function // Mock the global fetch function
global.fetch = jest.fn() global.fetch = jest.fn()
const mockFetch = (data: any, ok = true) => { // eslint-disable-line @typescript-eslint/no-explicit-any // Mock the toast object
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
},
}))
const mockFetch = (data: any, ok = true, statusText = "OK") => { // eslint-disable-line @typescript-eslint/no-explicit-any
;(fetch as jest.Mock).mockResolvedValueOnce({ ;(fetch as jest.Mock).mockResolvedValueOnce({
ok, ok,
json: () => Promise.resolve(data), json: () => Promise.resolve(data),
statusText,
}) })
} }
const mockFetchError = (data: any = {}) => { // eslint-disable-line @typescript-eslint/no-explicit-any const mockFetchError = (data: any = {}, statusText = "Bad Request") => { // eslint-disable-line @typescript-eslint/no-explicit-any
;(fetch as jest.Mock).mockResolvedValueOnce({ ;(fetch as jest.Mock).mockResolvedValueOnce({
ok: false, ok: false,
json: () => Promise.resolve(data), json: () => Promise.resolve(data),
statusText,
}) })
} }
@@ -36,6 +47,7 @@ describe("API Functions", () => {
beforeEach(() => { beforeEach(() => {
// Reset the mock before each test // Reset the mock before each test
;(fetch as jest.Mock).mockClear() ;(fetch as jest.Mock).mockClear()
;(toast.error as jest.Mock).mockClear()
}) })
describe("getNewsletters", () => { describe("getNewsletters", () => {
@@ -48,18 +60,26 @@ describe("API Functions", () => {
const newsletters = await getNewsletters() const newsletters = await getNewsletters()
expect(newsletters).toEqual(mockNewsletters) expect(newsletters).toEqual(mockNewsletters)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {})
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if fetching newsletters fails", async () => { it("should throw an error and show toast if fetching newsletters fails with HTTP error", async () => {
mockFetchError() mockFetchError({}, "Not Found")
await expect(getNewsletters()).rejects.toThrow("Failed to fetch newsletters") await expect(getNewsletters()).rejects.toThrow("Failed to fetch newsletters: Not Found")
expect(toast.error).toHaveBeenCalledWith("Failed to fetch newsletters: Not Found")
})
it("should throw an error and show toast if fetching newsletters fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(getNewsletters()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
describe("createNewsletter", () => { describe("createNewsletter", () => {
it("should create a newsletter successfully", async () => { it("should create a newsletter successfully", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: ["test@example.com"] } const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: ["test@example.com"], extract_content: false }
const createdNewsletter = { const createdNewsletter = {
id: 3, id: 3,
...newNewsletter, ...newNewsletter,
@@ -76,18 +96,27 @@ describe("API Functions", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(newNewsletter), body: JSON.stringify(newNewsletter),
}) })
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if creating newsletter fails", async () => { it("should throw an error and show toast if creating newsletter fails with HTTP error", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [] } const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false }
mockFetchError() mockFetchError({}, "Conflict")
await expect(createNewsletter(newNewsletter)).rejects.toThrow("Failed to create newsletter") await expect(createNewsletter(newNewsletter)).rejects.toThrow("Failed to create newsletter: Conflict")
expect(toast.error).toHaveBeenCalledWith("Failed to create newsletter: Conflict")
})
it("should throw an error and show toast if creating newsletter fails with network error", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false }
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(createNewsletter(newNewsletter)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
describe("updateNewsletter", () => { describe("updateNewsletter", () => {
it("should update a newsletter successfully", async () => { it("should update a newsletter successfully", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: ["updated@example.com"] } const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: ["updated@example.com"], extract_content: true }
const newsletterId = 1 const newsletterId = 1
const returnedNewsletter = { const returnedNewsletter = {
id: newsletterId, id: newsletterId,
@@ -105,13 +134,23 @@ describe("API Functions", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedNewsletter), body: JSON.stringify(updatedNewsletter),
}) })
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if updating newsletter fails", async () => { it("should throw an error and show toast if updating newsletter fails with HTTP error", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [] } const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true }
const newsletterId = 1 const newsletterId = 1
mockFetchError() mockFetchError({}, "Bad Request")
await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Failed to update newsletter") await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Failed to update newsletter: Bad Request")
expect(toast.error).toHaveBeenCalledWith("Failed to update newsletter: Bad Request")
})
it("should throw an error and show toast if updating newsletter fails with network error", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true }
const newsletterId = 1
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
@@ -124,12 +163,21 @@ describe("API Functions", () => {
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, { expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, {
method: "DELETE", method: "DELETE",
}) })
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if deleting newsletter fails", async () => { it("should throw an error and show toast if deleting newsletter fails with HTTP error", async () => {
const newsletterId = 1 const newsletterId = 1
mockFetchError() mockFetchError({}, "Forbidden")
await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Failed to delete newsletter") await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Failed to delete newsletter: Forbidden")
expect(toast.error).toHaveBeenCalledWith("Failed to delete newsletter: Forbidden")
})
it("should throw an error and show toast if deleting newsletter fails with network error", async () => {
const newsletterId = 1
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
@@ -150,12 +198,20 @@ describe("API Functions", () => {
const settings = await getSettings() const settings = await getSettings()
expect(settings).toEqual(mockSettings) expect(settings).toEqual(mockSettings)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {})
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if fetching settings fails", async () => { it("should throw an error and show toast if fetching settings fails with HTTP error", async () => {
mockFetchError() mockFetchError({}, "Unauthorized")
await expect(getSettings()).rejects.toThrow("Failed to fetch settings") await expect(getSettings()).rejects.toThrow("Failed to fetch settings: Unauthorized")
expect(toast.error).toHaveBeenCalledWith("Failed to fetch settings: Unauthorized")
})
it("should throw an error and show toast if fetching settings fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(getSettings()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
@@ -181,9 +237,10 @@ describe("API Functions", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(newSettings), body: JSON.stringify(newSettings),
}) })
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error if updating settings fails", async () => { it("should throw an error and show toast if updating settings fails with HTTP error", async () => {
const newSettings: SettingsCreate = { const newSettings: SettingsCreate = {
imap_server: "new.imap.com", imap_server: "new.imap.com",
imap_username: "newuser@example.com", imap_username: "newuser@example.com",
@@ -192,8 +249,23 @@ describe("API Functions", () => {
email_check_interval: 120, email_check_interval: 120,
auto_add_new_senders: true, auto_add_new_senders: true,
} }
mockFetchError() mockFetchError({}, "Internal Server Error")
await expect(updateSettings(newSettings)).rejects.toThrow("Failed to update settings") await expect(updateSettings(newSettings)).rejects.toThrow("Failed to update settings: Internal Server Error")
expect(toast.error).toHaveBeenCalledWith("Failed to update settings: Internal Server Error")
})
it("should throw an error and show toast if updating settings fails with network error", async () => {
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
search_folder: "Archive",
mark_as_read: false,
email_check_interval: 120,
auto_add_new_senders: true,
}
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(updateSettings(newSettings)).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
@@ -204,13 +276,22 @@ describe("API Functions", () => {
const folders = await getImapFolders() const folders = await getImapFolders()
expect(folders).toEqual(mockFolders) expect(folders).toEqual(mockFolders)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`, {})
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should return an empty array if fetching IMAP folders fails", async () => { it("should return an empty array and show toast if fetching IMAP folders fails with HTTP error", async () => {
mockFetchError() mockFetchError({}, "Forbidden")
const folders = await getImapFolders() const folders = await getImapFolders()
expect(folders).toEqual([]) expect(folders).toEqual([])
expect(toast.error).toHaveBeenCalledWith("Failed to fetch IMAP folders: Forbidden")
})
it("should return an empty array and show toast if fetching IMAP folders fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
const folders = await getImapFolders()
expect(folders).toEqual([])
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })
@@ -224,17 +305,59 @@ describe("API Functions", () => {
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/test`, { expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/test`, {
method: "POST", method: "POST",
}) })
expect(toast.error).not.toHaveBeenCalled()
}) })
it("should throw an error with detail if testing IMAP connection fails", async () => { it("should throw an error with detail and show toast if testing IMAP connection fails with HTTP error", async () => {
const errorMessage = "Invalid credentials" const errorMessage = "Invalid credentials"
mockFetchError({ detail: errorMessage }) mockFetchError({ detail: errorMessage }, "Unauthorized")
await expect(testImapConnection()).rejects.toThrow(errorMessage) await expect(testImapConnection()).rejects.toThrow(errorMessage)
expect(toast.error).toHaveBeenCalledWith(errorMessage)
}) })
it("should throw a generic error if testing IMAP connection fails without detail", async () => { it("should throw a generic error and show toast if testing IMAP connection fails without detail with HTTP error", async () => {
mockFetchError() mockFetchError({}, "Bad Gateway")
await expect(testImapConnection()).rejects.toThrow("Failed to test IMAP connection") await expect(testImapConnection()).rejects.toThrow("Failed to test IMAP connection: Bad Gateway")
expect(toast.error).toHaveBeenCalledWith("Failed to test IMAP connection: Bad Gateway")
})
it("should throw an error and show toast if testing IMAP connection fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(testImapConnection()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
})
})
describe("processEmails", () => {
it("should process emails successfully", async () => {
const mockResponse = { message: "Emails processed" }
mockFetch(mockResponse)
const result = await processEmails()
expect(result).toEqual(mockResponse)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/process`, {
method: "POST",
})
expect(toast.error).not.toHaveBeenCalled()
})
it("should throw an error with detail and show toast if processing emails fails with HTTP error", async () => {
const errorMessage = "IMAP not configured"
mockFetchError({ detail: errorMessage }, "Bad Request")
await expect(processEmails()).rejects.toThrow(errorMessage)
expect(toast.error).toHaveBeenCalledWith(errorMessage)
})
it("should throw a generic error and show toast if processing emails fails without detail with HTTP error", async () => {
mockFetchError({}, "Service Unavailable")
await expect(processEmails()).rejects.toThrow("Failed to process emails: Service Unavailable")
expect(toast.error).toHaveBeenCalledWith("Failed to process emails: Service Unavailable")
})
it("should throw an error and show toast if processing emails fails with network error", async () => {
;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed"))
await expect(processEmails()).rejects.toThrow("Network request failed")
expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.")
}) })
}) })

View File

@@ -1,5 +1,3 @@
// frontend/src/lib/api.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export interface Sender { export interface Sender {
@@ -53,102 +51,102 @@ export interface SettingsCreate {
} }
export async function getNewsletters(): Promise<Newsletter[]> { import { toast } from "sonner";
const response = await fetch(`${API_BASE_URL}/newsletters`);
if (!response.ok) { async function fetcher<T>(
throw new Error("Failed to fetch newsletters"); url: string,
options: RequestInit = {},
errorMessagePrefix: string,
returnEmptyArrayOnFailure: boolean = false
): Promise<T> {
try {
const response = await fetch(url, options);
if (!response.ok) {
let errorText = `${errorMessagePrefix}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.detail) {
errorText = errorData.detail;
}
} catch (e) { // eslint-disable-line @typescript-eslint/no-unused-vars
// ignore error if response is not JSON
}
toast.error(errorText);
if (returnEmptyArrayOnFailure) {
return [] as T;
}
throw new Error(errorText);
}
return response.json();
} catch (error) {
if (error instanceof TypeError) {
toast.error("Network error: Could not connect to the backend.");
}
if (returnEmptyArrayOnFailure) {
return [] as T;
}
throw error;
} }
return response.json(); }
export async function getNewsletters(): Promise<Newsletter[]> {
return fetcher<Newsletter[]>(`${API_BASE_URL}/newsletters`, {}, "Failed to fetch newsletters");
} }
export async function createNewsletter(newsletter: NewsletterCreate): Promise<Newsletter> { export async function createNewsletter(newsletter: NewsletterCreate): Promise<Newsletter> {
const response = await fetch(`${API_BASE_URL}/newsletters`, { return fetcher<Newsletter>(`${API_BASE_URL}/newsletters`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(newsletter), body: JSON.stringify(newsletter),
}); }, "Failed to create newsletter");
if (!response.ok) {
throw new Error("Failed to create newsletter");
}
return response.json();
} }
export async function updateNewsletter(id: number, newsletter: NewsletterUpdate): Promise<Newsletter> { export async function updateNewsletter(id: number, newsletter: NewsletterUpdate): Promise<Newsletter> {
const response = await fetch(`${API_BASE_URL}/newsletters/${id}`, { return fetcher<Newsletter>(`${API_BASE_URL}/newsletters/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(newsletter), body: JSON.stringify(newsletter),
}); }, "Failed to update newsletter");
if (!response.ok) {
throw new Error("Failed to update newsletter");
}
return response.json();
} }
export async function deleteNewsletter(id: number): Promise<void> { export async function deleteNewsletter(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/newsletters/${id}`, { await fetcher<void>(`${API_BASE_URL}/newsletters/${id}`, {
method: 'DELETE', method: 'DELETE',
}); }, "Failed to delete newsletter");
if (!response.ok) {
throw new Error("Failed to delete newsletter");
}
} }
export async function getSettings(): Promise<Settings> { export async function getSettings(): Promise<Settings> {
const response = await fetch(`${API_BASE_URL}/imap/settings`); return fetcher<Settings>(`${API_BASE_URL}/imap/settings`, {}, "Failed to fetch settings");
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
return response.json();
} }
export async function updateSettings(settings: SettingsCreate): Promise<Settings> { export async function updateSettings(settings: SettingsCreate): Promise<Settings> {
const response = await fetch(`${API_BASE_URL}/imap/settings`, { return fetcher<Settings>(`${API_BASE_URL}/imap/settings`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(settings), body: JSON.stringify(settings),
}); }, "Failed to update settings");
if (!response.ok) {
throw new Error("Failed to update settings");
}
return response.json();
} }
export async function getImapFolders(): Promise<string[]> { export async function getImapFolders(): Promise<string[]> {
const response = await fetch(`${API_BASE_URL}/imap/folders`); return fetcher<string[]>(`${API_BASE_URL}/imap/folders`, {}, "Failed to fetch IMAP folders", true);
// If it fails, it's probably because settings are not configured. Return empty array.
if (!response.ok) {
return [];
}
return response.json();
} }
export async function testImapConnection(): Promise<{ message: string }> { export async function testImapConnection(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE_URL}/imap/test`, { return fetcher<{ message: string }>(`${API_BASE_URL}/imap/test`, {
method: 'POST', method: 'POST',
}); }, "Failed to test IMAP connection");
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to test IMAP connection");
}
return response.json();
} }
export async function processEmails(): Promise<{ message: string }> { export async function processEmails(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE_URL}/imap/process`, { return fetcher<{ message: string }>(`${API_BASE_URL}/imap/process`, {
method: 'POST', method: 'POST',
}); }, "Failed to process emails");
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to process emails");
}
return response.json();
} }
export function getFeedUrl(newsletterId: number): string { export function getFeedUrl(newsletterId: number): string {