Skip to content

Commit 51478eb

Browse files
committed
test(service-worker): add race condition prevention tests
Verify safeBasic filtering and version-bump mechanisms: - safeBasic excludes pre-existing notifications removed during fetch - safeBasic unconditionally keeps brand-new notifications - notificationFetchVersion bump during safeBasic re-read aborts the write
1 parent 8a0f4be commit 51478eb

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

tests/service-worker.test.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,128 @@ describe('service-worker', () => {
748748
expect(mockAction.setBadgeText).toHaveBeenCalledWith({ text: '2' });
749749
});
750750
});
751+
752+
describe('race condition prevention', () => {
753+
// Minimal raw GitHub API notification shape
754+
const makeRawNotif = (id) => ({
755+
id,
756+
subject: { title: `Issue ${id}`, type: 'Issue', url: null },
757+
reason: 'mention',
758+
unread: true,
759+
updated_at: '2024-01-01T00:00:00Z',
760+
repository: { name: 'repo', full_name: 'owner/repo', html_url: 'https://github.com/owner/repo' },
761+
});
762+
763+
// Minimal stored notification shape (as returned by storage)
764+
const makeStoredNotif = (id) => ({
765+
id,
766+
updated_at: '2024-01-01T00:00:00Z',
767+
type: 'Issue',
768+
});
769+
770+
beforeEach(() => {
771+
mockGithub.isAuthenticated = true;
772+
mockGithub.pollInterval = 60;
773+
});
774+
775+
it('safeBasic should exclude pre-existing notifications removed during the fetch', async () => {
776+
// GitHub returns A and B; A was already in storage before the fetch
777+
mockGithub.getNotifications.mockResolvedValue({
778+
items: [makeRawNotif('A'), makeRawNotif('B')],
779+
hasMore: false,
780+
});
781+
782+
// First getNotifications call: existingIds snapshot (both A and B present)
783+
// Second getNotifications call: safeBasic re-read (A was removed by markAsRead during the fetch)
784+
mockStorageFunctions.getNotifications
785+
.mockResolvedValueOnce([makeStoredNotif('A'), makeStoredNotif('B')])
786+
.mockResolvedValueOnce([makeStoredNotif('B')]);
787+
788+
messageHandler({ action: 'refresh' }, {}, vi.fn());
789+
await new Promise((resolve) => setTimeout(resolve, 100));
790+
791+
// Find the basic-save setNotifications call (an array write, not the badge)
792+
const writeCalls = mockStorageFunctions.setNotifications.mock.calls;
793+
expect(writeCalls.length).toBeGreaterThan(0);
794+
795+
// The safeBasic write should exclude A (removed during fetch)
796+
const basicWrite = writeCalls[0][0];
797+
expect(basicWrite.map((n) => n.id)).not.toContain('A');
798+
expect(basicWrite.map((n) => n.id)).toContain('B');
799+
});
800+
801+
it('safeBasic should always keep new notifications not in existingIds', async () => {
802+
// GitHub returns A (existing) and C (brand new, not yet in storage)
803+
mockGithub.getNotifications.mockResolvedValue({
804+
items: [makeRawNotif('A'), makeRawNotif('C')],
805+
hasMore: false,
806+
});
807+
808+
// First getNotifications: only A existed before the fetch
809+
// Second getNotifications (safeBasic re-read): still only A in storage
810+
mockStorageFunctions.getNotifications
811+
.mockResolvedValueOnce([makeStoredNotif('A')])
812+
.mockResolvedValueOnce([makeStoredNotif('A')]);
813+
814+
messageHandler({ action: 'refresh' }, {}, vi.fn());
815+
await new Promise((resolve) => setTimeout(resolve, 100));
816+
817+
const writeCalls = mockStorageFunctions.setNotifications.mock.calls;
818+
expect(writeCalls.length).toBeGreaterThan(0);
819+
820+
const basicWrite = writeCalls[0][0];
821+
const writtenIds = basicWrite.map((n) => n.id);
822+
823+
// C is new (not in existingIds) → always kept unconditionally
824+
expect(writtenIds).toContain('C');
825+
// A is existing and still in storage → also kept
826+
expect(writtenIds).toContain('A');
827+
});
828+
829+
it('safeBasic should abort when notificationFetchVersion is bumped during re-read', async () => {
830+
// Hold the second getNotifications call (safeBasic re-read) until we manually release it
831+
let releaseSafeBasicRead;
832+
mockStorageFunctions.getNotifications
833+
.mockResolvedValueOnce([makeStoredNotif('A'), makeStoredNotif('B')]) // existingIds
834+
.mockImplementationOnce(
835+
() =>
836+
new Promise((resolve) => {
837+
releaseSafeBasicRead = resolve;
838+
}),
839+
); // safeBasic re-read held
840+
841+
mockGithub.getNotifications.mockResolvedValue({
842+
items: [makeRawNotif('A'), makeRawNotif('B')],
843+
hasMore: false,
844+
});
845+
846+
// Start checkNotifications (will pause at safeBasic re-read)
847+
messageHandler({ action: 'refresh' }, {}, vi.fn());
848+
await new Promise((resolve) => setTimeout(resolve, 30));
849+
850+
// markAsRead bumps notificationFetchVersion before safeBasic resumes
851+
// slot for markAsRead's own getNotifications call
852+
mockStorageFunctions.getNotifications.mockResolvedValueOnce([makeStoredNotif('B')]);
853+
mockGithub.markAsRead.mockResolvedValue(true);
854+
const markResponse = vi.fn();
855+
messageHandler({ action: 'markAsRead', notificationId: 'A' }, {}, markResponse);
856+
857+
// Wait for markAsRead to complete (bumps version and writes [B])
858+
await new Promise((resolve) => setTimeout(resolve, 50));
859+
860+
// Release safeBasic's getNotifications (version is already bumped)
861+
releaseSafeBasicRead([makeStoredNotif('B')]);
862+
await new Promise((resolve) => setTimeout(resolve, 30));
863+
864+
// setNotifications should only have been called by markAsRead (writing [B])
865+
// safeBasic should have aborted after detecting the version bump
866+
const writeCalls = mockStorageFunctions.setNotifications.mock.calls;
867+
// Every write should contain only B (not A)
868+
writeCalls.forEach((call) => {
869+
expect(call[0].map((n) => n.id)).not.toContain('A');
870+
});
871+
});
872+
});
751873
});
752874

753875
describe('service-worker helper functions', () => {

0 commit comments

Comments
 (0)