Skip to content

Commit 9e78e56

Browse files
committed
test(background-refresh): add error resilience coverage for hydration stability and exception safety
1 parent e3369ea commit 9e78e56

1 file changed

Lines changed: 107 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
2+
import { BackgroundRefresh } from './background-refresh';
3+
import { getFullDashboardData } from '../../lib/github';
4+
5+
vi.mock('../../lib/github', () => ({
6+
getFullDashboardData: vi.fn(),
7+
}));
8+
9+
describe('BackgroundRefresh - Error Resilience (Hydration Stability, Exception Safety & Error Fallbacks)', () => {
10+
let refresher: BackgroundRefresh;
11+
12+
beforeEach(() => {
13+
refresher = BackgroundRefresh['getInstance']();
14+
refresher.reset();
15+
vi.clearAllMocks();
16+
});
17+
18+
it('Exception Safety: does not throw or crash when getFullDashboardData rejects with an unexpected runtime exception', async () => {
19+
(getFullDashboardData as Mock).mockRejectedValue(
20+
new Error('Unexpected runtime exception: database connection refused')
21+
);
22+
23+
// triggerRefresh must never throw — the error is swallowed internally
24+
expect(() => refresher.triggerRefresh('crash_user')).not.toThrow();
25+
26+
// Wait for the microtask queue to settle
27+
await new Promise(process.nextTick);
28+
29+
// Active job must be cleared after exception — no stuck state
30+
expect(refresher.isJobActive('crash_user')).toBe(false);
31+
});
32+
33+
it('Error Fallback: logs error to dev-telemetry (console.error) when background refresh fails', async () => {
34+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
35+
36+
(getFullDashboardData as Mock).mockRejectedValue(new Error('DB connectivity error'));
37+
38+
refresher.triggerRefresh('telemetry_user');
39+
40+
await new Promise(process.nextTick);
41+
42+
// Verify exception is logged to dev-telemetry tracker appropriately
43+
expect(consoleSpy).toHaveBeenCalledOnce();
44+
expect(consoleSpy.mock.calls[0][0]).toContain('[BackgroundRefresh]');
45+
expect(consoleSpy.mock.calls[0][0]).toContain('telemetry_user');
46+
47+
consoleSpy.mockRestore();
48+
});
49+
50+
it('Hydration Stability: active job set is fully cleared after exception so subsequent refresh attempts are not blocked', async () => {
51+
(getFullDashboardData as Mock).mockRejectedValueOnce(new Error('First call fails'));
52+
53+
refresher.triggerRefresh('hydration_user');
54+
55+
// Job is active during execution
56+
expect(refresher.isJobActive('hydration_user')).toBe(true);
57+
58+
await new Promise(process.nextTick);
59+
60+
// After failure, job must be cleared so a fresh trigger is accepted
61+
expect(refresher.isJobActive('hydration_user')).toBe(false);
62+
63+
// Second attempt must be accepted — no stuck/blocked state from prior failure
64+
(getFullDashboardData as Mock).mockResolvedValueOnce(undefined);
65+
refresher.triggerRefresh('hydration_user');
66+
expect(refresher.isJobActive('hydration_user')).toBe(true);
67+
68+
await new Promise(process.nextTick);
69+
});
70+
71+
it('Exception Safety: handles non-Error throws (strings, objects) without crashing', async () => {
72+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
73+
74+
// Simulate a non-Error throw (e.g. a plain string or object)
75+
(getFullDashboardData as Mock).mockRejectedValue('string error: server anomaly');
76+
77+
expect(() => refresher.triggerRefresh('non_error_user')).not.toThrow();
78+
79+
await new Promise(process.nextTick);
80+
81+
// Job must still be cleared even for non-Error throws
82+
expect(refresher.isJobActive('non_error_user')).toBe(false);
83+
84+
// Error must still be logged
85+
expect(consoleSpy).toHaveBeenCalledOnce();
86+
87+
consoleSpy.mockRestore();
88+
});
89+
90+
it('Error Fallback: reset() provides a clean recovery path clearing all active jobs including stuck ones', async () => {
91+
// Simulate two concurrent hanging jobs (never resolve)
92+
(getFullDashboardData as Mock).mockReturnValue(new Promise(() => {}));
93+
94+
refresher.triggerRefresh('stuck_user_1');
95+
refresher.triggerRefresh('stuck_user_2');
96+
97+
expect(refresher.isJobActive('stuck_user_1')).toBe(true);
98+
expect(refresher.isJobActive('stuck_user_2')).toBe(true);
99+
100+
// reset() is the user recovery/reload path available on error panels
101+
refresher.reset();
102+
103+
// All stuck jobs must be cleared after reset
104+
expect(refresher.isJobActive('stuck_user_1')).toBe(false);
105+
expect(refresher.isJobActive('stuck_user_2')).toBe(false);
106+
});
107+
});

0 commit comments

Comments
 (0)