Skip to content

Commit e796826

Browse files
committed
test: add error handling and resilience tests
- API error handling: timeout, non-retryable 4xx, retry exhaustion on 500 - Mutation retry: markAsRead 500 retry with linear backoff, 403 immediate fail - fetchUsername error responses: 401 scopes, 500 API message, non-JSON fallback - Storage write failures: quota exceeded during refresh, settings read rejection - Desktop notification batch: partial create failure continues remaining
1 parent d49729e commit e796826

2 files changed

Lines changed: 302 additions & 0 deletions

File tree

tests/github-api.test.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,3 +942,171 @@ describe("Device Flow error handling", () => {
942942
await rejection;
943943
});
944944
});
945+
946+
describe("error handling and retry - extended", () => {
947+
let mockFetch;
948+
949+
beforeEach(() => {
950+
vi.useFakeTimers();
951+
mockFetch = vi.fn();
952+
vi.stubGlobal("fetch", mockFetch);
953+
github.token = "test-token";
954+
github.rateLimit.isLimited = false;
955+
});
956+
957+
afterEach(() => {
958+
vi.useRealTimers();
959+
vi.restoreAllMocks();
960+
vi.unstubAllGlobals();
961+
});
962+
963+
it("should throw Request timeout when fetchUsername stalls past USER_INFO timeout", async () => {
964+
// fetchWithTimeout uses AbortController; simulate a hanging fetch that aborts
965+
mockFetch.mockImplementation((_url, options) => {
966+
return new Promise((_resolve, reject) => {
967+
options.signal.addEventListener("abort", () => {
968+
reject(new DOMException("The signal is aborted", "AbortError"));
969+
});
970+
});
971+
});
972+
973+
const promise = github.fetchUsername();
974+
const rejection = expect(promise).rejects.toThrow("Request timeout");
975+
976+
// USER_INFO timeout is 10000ms
977+
await vi.advanceTimersByTimeAsync(11000);
978+
979+
await rejection;
980+
});
981+
982+
it("should not retry getNotifications on 403 (non-retryable 4xx)", async () => {
983+
mockFetch.mockResolvedValue({
984+
ok: false,
985+
status: 403,
986+
headers: { get: () => null },
987+
});
988+
989+
await expect(github.getNotifications()).rejects.toThrow("403");
990+
// retryOn is [429, 500] — 403 should not be retried
991+
expect(mockFetch).toHaveBeenCalledTimes(1);
992+
});
993+
994+
it("should throw after exhausting all retries on 500 for getNotifications", async () => {
995+
// getNotifications: maxRetries=3, exponential backoff, baseDelay=1000ms
996+
// Delays: 1000, 2000, 4000 ms for attempts 0, 1, 2
997+
mockFetch.mockResolvedValue({
998+
ok: false,
999+
status: 500,
1000+
headers: { get: () => null },
1001+
});
1002+
1003+
const promise = github.getNotifications();
1004+
const rejection = expect(promise).rejects.toThrow("500");
1005+
1006+
// Advance past each retry delay
1007+
await vi.advanceTimersByTimeAsync(1000);
1008+
await vi.advanceTimersByTimeAsync(2000);
1009+
await vi.advanceTimersByTimeAsync(4000);
1010+
1011+
await rejection;
1012+
// Initial attempt + 3 retries = 4 calls
1013+
expect(mockFetch).toHaveBeenCalledTimes(4);
1014+
});
1015+
1016+
it("should retry markAsRead on 500 twice and succeed on third attempt", async () => {
1017+
// RETRY_MUTATION_OPTIONS: maxRetries=2, linear backoff, baseDelay=500ms
1018+
// Linear delays: 500*(0+1)=500, 500*(1+1)=1000
1019+
mockFetch
1020+
.mockResolvedValueOnce({
1021+
ok: false,
1022+
status: 500,
1023+
headers: { get: () => null },
1024+
})
1025+
.mockResolvedValueOnce({
1026+
ok: false,
1027+
status: 500,
1028+
headers: { get: () => null },
1029+
})
1030+
.mockResolvedValueOnce({
1031+
ok: true,
1032+
status: 205,
1033+
headers: { get: () => null },
1034+
});
1035+
1036+
const promise = github.markAsRead("12345");
1037+
1038+
// Advance past first retry delay (500ms)
1039+
await vi.advanceTimersByTimeAsync(500);
1040+
// Advance past second retry delay (1000ms)
1041+
await vi.advanceTimersByTimeAsync(1000);
1042+
1043+
const result = await promise;
1044+
1045+
expect(result).toBe(true);
1046+
expect(mockFetch).toHaveBeenCalledTimes(3);
1047+
});
1048+
1049+
it("should not retry markAsRead on 403 (mutation non-retryable)", async () => {
1050+
// RETRY_MUTATION_OPTIONS retryOn: [500] — 403 should not be retried
1051+
mockFetch.mockResolvedValue({
1052+
ok: false,
1053+
status: 403,
1054+
headers: { get: () => null },
1055+
});
1056+
1057+
await expect(github.markAsRead("12345")).rejects.toThrow("403");
1058+
expect(mockFetch).toHaveBeenCalledTimes(1);
1059+
});
1060+
});
1061+
1062+
describe("fetchUsername error responses", () => {
1063+
let mockFetch;
1064+
1065+
beforeEach(() => {
1066+
mockFetch = vi.fn();
1067+
vi.stubGlobal("fetch", mockFetch);
1068+
github.token = "test-token";
1069+
});
1070+
1071+
afterEach(() => {
1072+
vi.restoreAllMocks();
1073+
vi.unstubAllGlobals();
1074+
});
1075+
1076+
it("should throw scopes message on 401", async () => {
1077+
mockFetch.mockResolvedValueOnce({
1078+
ok: false,
1079+
status: 401,
1080+
headers: { get: () => null },
1081+
json: () => Promise.resolve({ message: "Bad credentials" }),
1082+
});
1083+
1084+
await expect(github.fetchUsername()).rejects.toThrow(
1085+
"Invalid token or missing required scopes (repo, notifications)",
1086+
);
1087+
});
1088+
1089+
it("should throw with API message on 500", async () => {
1090+
mockFetch.mockResolvedValueOnce({
1091+
ok: false,
1092+
status: 500,
1093+
headers: { get: () => null },
1094+
json: () => Promise.resolve({ message: "Internal Server Error" }),
1095+
});
1096+
1097+
await expect(github.fetchUsername()).rejects.toThrow(
1098+
"Failed to fetch username: Internal Server Error",
1099+
);
1100+
});
1101+
1102+
it("should throw with status code when response body is not JSON", async () => {
1103+
mockFetch.mockResolvedValueOnce({
1104+
ok: false,
1105+
status: 502,
1106+
headers: { get: () => null },
1107+
json: () => Promise.reject(new SyntaxError("Unexpected token")),
1108+
});
1109+
1110+
await expect(github.fetchUsername()).rejects.toThrow("Failed to fetch username (HTTP 502)");
1111+
});
1112+
});

tests/service-worker.test.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,4 +1525,138 @@ describe("service-worker helper functions", () => {
15251525
consoleSpy.mockRestore();
15261526
});
15271527
});
1528+
1529+
describe("storage write failures during checkNotifications", () => {
1530+
beforeEach(() => {
1531+
vi.clearAllMocks();
1532+
mockGithub.isAuthenticated = true;
1533+
mockGithub.pollInterval = 60;
1534+
});
1535+
1536+
it("should set error badge when setNotifications rejects during refresh", async () => {
1537+
mockGithub.getNotifications.mockResolvedValue({
1538+
items: [
1539+
{
1540+
id: "1",
1541+
subject: { title: "Issue 1", type: "Issue", url: null },
1542+
reason: "mention",
1543+
unread: true,
1544+
updated_at: "2024-01-01T00:00:00Z",
1545+
repository: {
1546+
name: "repo",
1547+
full_name: "owner/repo",
1548+
html_url: "https://github.com/owner/repo",
1549+
},
1550+
},
1551+
],
1552+
hasMore: false,
1553+
});
1554+
1555+
mockStorageFunctions.getNotifications.mockResolvedValue([]);
1556+
mockStorageFunctions.setNotifications.mockRejectedValue(new Error("Storage quota exceeded"));
1557+
1558+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1559+
1560+
const sendResponse = vi.fn();
1561+
messageHandler({ action: "refresh" }, {}, sendResponse);
1562+
await new Promise((resolve) => setTimeout(resolve, 100));
1563+
1564+
// The error should propagate to checkNotifications' catch block
1565+
// which sets an error title on the badge
1566+
expect(mockAction.setTitle).toHaveBeenCalledWith(
1567+
expect.objectContaining({
1568+
title: expect.stringContaining("Storage quota exceeded"),
1569+
}),
1570+
);
1571+
1572+
consoleSpy.mockRestore();
1573+
});
1574+
1575+
it("should handle getEnableDesktopNotifications rejection gracefully", async () => {
1576+
mockGithub.getNotifications.mockResolvedValue({
1577+
items: [
1578+
{
1579+
id: "1",
1580+
subject: { title: "Issue 1", type: "Issue", url: null },
1581+
reason: "mention",
1582+
unread: true,
1583+
updated_at: "2024-01-01T00:00:00Z",
1584+
repository: {
1585+
name: "repo",
1586+
full_name: "owner/repo",
1587+
html_url: "https://github.com/owner/repo",
1588+
},
1589+
},
1590+
],
1591+
hasMore: false,
1592+
});
1593+
1594+
mockStorageFunctions.getNotifications.mockResolvedValue([]);
1595+
mockStorageFunctions.setNotifications.mockResolvedValue(undefined);
1596+
// Desktop notification preference fetch fails
1597+
mockStorageFunctions.getEnableDesktopNotifications.mockRejectedValue(
1598+
new Error("Storage read error"),
1599+
);
1600+
1601+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1602+
1603+
const sendResponse = vi.fn();
1604+
messageHandler({ action: "refresh" }, {}, sendResponse);
1605+
await new Promise((resolve) => setTimeout(resolve, 100));
1606+
1607+
// showDesktopNotificationsForNew catches its own errors internally,
1608+
// so the refresh should still succeed
1609+
expect(sendResponse).toHaveBeenCalledWith({ success: true });
1610+
1611+
consoleSpy.mockRestore();
1612+
});
1613+
});
1614+
1615+
describe("desktop notification partial failure in batch", () => {
1616+
beforeEach(() => {
1617+
vi.clearAllMocks();
1618+
vi.useFakeTimers();
1619+
});
1620+
1621+
afterEach(() => {
1622+
vi.useRealTimers();
1623+
});
1624+
1625+
it("should continue creating remaining notifications when one create call fails", async () => {
1626+
mockStorageFunctions.getEnableDesktopNotifications.mockResolvedValue(true);
1627+
mockStorageFunctions.getMaxDesktopNotifications.mockResolvedValue(5);
1628+
1629+
// First notification.create succeeds, second fails, third succeeds
1630+
mockNotifications.create
1631+
.mockResolvedValueOnce("notif-1")
1632+
.mockRejectedValueOnce(new Error("Notification create failed"))
1633+
.mockResolvedValueOnce("notif-3");
1634+
1635+
const notifications = [
1636+
{ id: "1", isNew: true, title: "N1", repository: { full_name: "r1" }, reason: "mention" },
1637+
{ id: "2", isNew: true, title: "N2", repository: { full_name: "r2" }, reason: "assign" },
1638+
{ id: "3", isNew: true, title: "N3", repository: { full_name: "r3" }, reason: "review" },
1639+
];
1640+
1641+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1642+
1643+
const promise = showDesktopNotificationsForNew(notifications);
1644+
await vi.runAllTimersAsync();
1645+
await promise;
1646+
1647+
// All three should be attempted (showDesktopNotification catches its own errors)
1648+
expect(mockNotifications.create).toHaveBeenCalledTimes(3);
1649+
// First and third should have been called with correct IDs
1650+
expect(mockNotifications.create).toHaveBeenCalledWith(
1651+
`${NOTIFICATION_ID_PREFIX}1`,
1652+
expect.any(Object),
1653+
);
1654+
expect(mockNotifications.create).toHaveBeenCalledWith(
1655+
`${NOTIFICATION_ID_PREFIX}3`,
1656+
expect.any(Object),
1657+
);
1658+
1659+
consoleSpy.mockRestore();
1660+
});
1661+
});
15281662
});

0 commit comments

Comments
 (0)