style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -12,19 +12,19 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock DB — the factory is hoisted; per-test configuration uses mockReturnValueOnce
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock pushDispatcher
|
||||
vi.mock('../../src/lib/pushDispatcher.js', () => ({
|
||||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: configure db.select for two sequential calls used by dispatchEventChange.
|
||||
@@ -46,9 +46,11 @@ function setupDbMock(
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(
|
||||
actorDisplayName !== null ? [{ displayName: actorDisplayName }] : [],
|
||||
),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
actorDisplayName !== null ? [{ displayName: actorDisplayName }] : [],
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -57,40 +59,52 @@ function setupDbMock(
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(subRows),
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
beforeEach(() => {
|
||||
// resetAllMocks clears call history AND resets mockReturnValueOnce queues,
|
||||
// preventing leaked once-queues from test N polluting test N+1.
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('dispatches for a new event (operation=create)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'new-event-uid', title: 'Doctor appointment', operation: 'create' },
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches for an event with a title change', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{
|
||||
@@ -100,21 +114,21 @@ describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
changedFields: ['title'],
|
||||
},
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT dispatch for a description-only edit (D-04)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
// isMeaningfulChange returns false before DB is queried — select won't be called.
|
||||
// Set up mock anyway as a no-op guard.
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [
|
||||
{ id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' },
|
||||
])
|
||||
]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{
|
||||
@@ -124,64 +138,76 @@ describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
changedFields: ['description'], // description-only — must NOT fire
|
||||
},
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('excludes the actor user subscriptions from dispatch (D-03)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
// DB's ne() filter already excludes the actor; simulate that the DB returns
|
||||
// only the actor's own sub (userId=1) to test the application-level guard.
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [
|
||||
{ id: 1, userId: 1, endpoint: 'https://push.example.com/sub/1', p256dh: 'x', auth: 'y' },
|
||||
])
|
||||
]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-3', title: 'Soccer practice', operation: 'create' },
|
||||
/* actorUserId */ 1, // actor is userId=1 — their subscription must be excluded
|
||||
)
|
||||
);
|
||||
|
||||
// No subscriptions remain after excluding the actor — nothing dispatched
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the actor in the notification title (IN-01 / D-02/D-03)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-4', title: 'Dentist', operation: 'create' },
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1]
|
||||
expect(calledWith.title).toContain('Lucas')
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1];
|
||||
expect(calledWith.title).toContain('Lucas');
|
||||
});
|
||||
|
||||
it('falls back to "A family member" when actor row is missing (IN-01)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), null, [otherUserSub]) // actor row absent → empty array
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), null, [otherUserSub]); // actor row absent → empty array
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-5', title: 'Soccer', operation: 'create' },
|
||||
/* actorUserId */ 99, // non-existent user
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1]
|
||||
expect(calledWith.title).toContain('A family member')
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1];
|
||||
expect(calledWith.title).toContain('A family member');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,81 +11,87 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { users, lists, listShares } from '../../src/db/schema.js'
|
||||
import { getAccessibleListIds } from '../../src/lib/listAccess.js'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, lists, listShares } from '../../src/db/schema.js';
|
||||
import { getAccessibleListIds } from '../../src/lib/listAccess.js';
|
||||
|
||||
// Seed helpers — insert minimal rows and return their IDs.
|
||||
// oidc_sub uses a UUID suffix so rows never collide across test runs
|
||||
// even when the users table is not truncated between runs.
|
||||
async function seedUser(label: string): Promise<number> {
|
||||
const [result] = await db.insert(users).values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#000000',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#000000',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedList(ownerId: number, isShared = false): Promise<number> {
|
||||
const [result] = await db.insert(lists).values({
|
||||
ownerId,
|
||||
name: `List ${Math.random()}`,
|
||||
isShared,
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(lists)
|
||||
.values({
|
||||
ownerId,
|
||||
name: `List ${Math.random()}`,
|
||||
isShared,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function shareList(listId: number, userId: number): Promise<void> {
|
||||
await db.insert(listShares).values({ listId, userId })
|
||||
await db.insert(listShares).values({ listId, userId });
|
||||
}
|
||||
|
||||
describe('listAccess — getAccessibleListIds (D-04)', () => {
|
||||
it('returns ids of lists the user owns (Test 5)', async () => {
|
||||
const userId = await seedUser('owner-5')
|
||||
const listId1 = await seedList(userId)
|
||||
const listId2 = await seedList(userId)
|
||||
const userId = await seedUser('owner-5');
|
||||
const listId1 = await seedList(userId);
|
||||
const listId2 = await seedList(userId);
|
||||
|
||||
const ids = await getAccessibleListIds(userId)
|
||||
const ids = await getAccessibleListIds(userId);
|
||||
|
||||
expect(ids).toContain(listId1)
|
||||
expect(ids).toContain(listId2)
|
||||
})
|
||||
expect(ids).toContain(listId1);
|
||||
expect(ids).toContain(listId2);
|
||||
});
|
||||
|
||||
it('returns ids of lists shared to the user via list_shares (Test 6)', async () => {
|
||||
const ownerUserId = await seedUser('owner-6')
|
||||
const sharedUserId = await seedUser('shared-6')
|
||||
const sharedListId = await seedList(ownerUserId, true)
|
||||
await shareList(sharedListId, sharedUserId)
|
||||
const ownerUserId = await seedUser('owner-6');
|
||||
const sharedUserId = await seedUser('shared-6');
|
||||
const sharedListId = await seedList(ownerUserId, true);
|
||||
await shareList(sharedListId, sharedUserId);
|
||||
|
||||
const ids = await getAccessibleListIds(sharedUserId)
|
||||
const ids = await getAccessibleListIds(sharedUserId);
|
||||
|
||||
expect(ids).toContain(sharedListId)
|
||||
})
|
||||
expect(ids).toContain(sharedListId);
|
||||
});
|
||||
|
||||
it('does NOT return another user\'s private non-shared list id (Test 7 — D-04 negative)', async () => {
|
||||
const ownerUserId = await seedUser('owner-7')
|
||||
const otherUserId = await seedUser('other-7')
|
||||
const privateListId = await seedList(ownerUserId, false)
|
||||
it("does NOT return another user's private non-shared list id (Test 7 — D-04 negative)", async () => {
|
||||
const ownerUserId = await seedUser('owner-7');
|
||||
const otherUserId = await seedUser('other-7');
|
||||
const privateListId = await seedList(ownerUserId, false);
|
||||
// Deliberately NOT sharing privateListId with otherUserId
|
||||
|
||||
const ids = await getAccessibleListIds(otherUserId)
|
||||
const ids = await getAccessibleListIds(otherUserId);
|
||||
|
||||
expect(ids).not.toContain(privateListId)
|
||||
})
|
||||
expect(ids).not.toContain(privateListId);
|
||||
});
|
||||
|
||||
it('result has no duplicates when a list is both owned and shared to the owner (Test 8)', async () => {
|
||||
const userId = await seedUser('owner-8')
|
||||
const listId = await seedList(userId, true)
|
||||
const userId = await seedUser('owner-8');
|
||||
const listId = await seedList(userId, true);
|
||||
// Erroneously share the list back to the owner (degenerate case)
|
||||
await shareList(listId, userId)
|
||||
await shareList(listId, userId);
|
||||
|
||||
const ids = await getAccessibleListIds(userId)
|
||||
const ids = await getAccessibleListIds(userId);
|
||||
|
||||
const occurrences = ids.filter((id) => id === listId)
|
||||
expect(occurrences).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
const occurrences = ids.filter((id) => id === listId);
|
||||
expect(occurrences).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,72 @@
|
||||
* pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedUser(label: string, displayName?: string): Promise<number> {
|
||||
const [result] = await db.insert(users).values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: displayName ?? `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: displayName ?? `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedList(ownerId: number, name: string, isShared = true): Promise<number> {
|
||||
const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId()
|
||||
return result.id
|
||||
const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedShare(listId: number, userId: number): Promise<void> {
|
||||
await db.insert(listShares).values({ listId, userId })
|
||||
await db.insert(listShares).values({ listId, userId });
|
||||
}
|
||||
|
||||
async function seedSubscription(userId: number, suffix = ''): Promise<number> {
|
||||
const [result] = await db.insert(pushSubscriptions).values({
|
||||
userId,
|
||||
endpoint: `https://push.example.com/${userId}${suffix}`,
|
||||
p256dh: 'fake-p256dh-key',
|
||||
auth: 'fake-auth',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(pushSubscriptions)
|
||||
.values({
|
||||
userId,
|
||||
endpoint: `https://push.example.com/${userId}${suffix}`,
|
||||
p256dh: 'fake-p256dh-key',
|
||||
auth: 'fake-auth',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** Short wait for real-timer coalescer window + async DB queries to settle. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a predicate until it passes or the timeout is exceeded.
|
||||
* Replaces @testing-library/waitFor — keeps the test deps minimal.
|
||||
*/
|
||||
async function pollUntil(
|
||||
predicate: () => void,
|
||||
timeoutMs = 3000,
|
||||
intervalMs = 30,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastErr: unknown
|
||||
async function pollUntil(predicate: () => void, timeoutMs = 3000, intervalMs = 30): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastErr: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
predicate()
|
||||
return // predicate passed
|
||||
predicate();
|
||||
return; // predicate passed
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
lastErr = err;
|
||||
}
|
||||
await sleep(intervalMs)
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw lastErr
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,134 +93,134 @@ async function pollUntil(
|
||||
// Use vi.doMock + vi.resetModules before each test so the mock is fresh.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WINDOW_MS = 10
|
||||
const WINDOW_MS = 10;
|
||||
|
||||
describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetModules();
|
||||
// Re-register the dispatchPush mock after each resetModules so fresh
|
||||
// dynamic imports of listChangeDispatcher.js get the mocked pushDispatcher.
|
||||
vi.doMock('../../src/lib/pushDispatcher.js', () => ({
|
||||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
async function getDispatchPushMock() {
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
return vi.mocked(dispatchPush)
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
return vi.mocked(dispatchPush);
|
||||
}
|
||||
|
||||
it('burst of N calls coalesces into exactly one dispatchPush to the non-actor subscriber', async () => {
|
||||
const actorId = await seedUser('actor-burst', 'Alice')
|
||||
const otherId = await seedUser('other-burst', 'Bob')
|
||||
const listId = await seedList(actorId, 'Groceries')
|
||||
await seedShare(listId, otherId)
|
||||
const actorId = await seedUser('actor-burst', 'Alice');
|
||||
const otherId = await seedUser('other-burst', 'Bob');
|
||||
const listId = await seedList(actorId, 'Groceries');
|
||||
await seedShare(listId, otherId);
|
||||
|
||||
// Subscriptions: one for actor, one for other
|
||||
await seedSubscription(actorId)
|
||||
const otherSubId = await seedSubscription(otherId)
|
||||
await seedSubscription(actorId);
|
||||
const otherSubId = await seedSubscription(otherId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
// Three rapid calls within the coalesce window
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait for the coalescer window to expire + DB queries to settle
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The called subscription must be the other user's subscription
|
||||
const [calledSub, notification] = mockDispatch.mock.calls[0]
|
||||
expect(calledSub.id).toBe(otherSubId)
|
||||
expect(calledSub.userId).toBe(otherId)
|
||||
const [calledSub, notification] = mockDispatch.mock.calls[0];
|
||||
expect(calledSub.id).toBe(otherSubId);
|
||||
expect(calledSub.userId).toBe(otherId);
|
||||
|
||||
// Notification body must contain actor name and change count
|
||||
expect(notification.body).toMatch(/Alice/)
|
||||
expect(notification.body).toMatch(/3/)
|
||||
expect(notification.body).toMatch(/Alice/);
|
||||
expect(notification.body).toMatch(/3/);
|
||||
// Notification must have a title (D-02 — no item text, just generic copy)
|
||||
expect(notification.title).toBeDefined()
|
||||
expect(typeof notification.title).toBe('string')
|
||||
})
|
||||
expect(notification.title).toBeDefined();
|
||||
expect(typeof notification.title).toBe('string');
|
||||
});
|
||||
|
||||
it('actor is never dispatched to their own subscription (D-03 self-suppression)', async () => {
|
||||
const actorId = await seedUser('actor-self-suppress', 'Charlie')
|
||||
const listId = await seedList(actorId, 'Private List')
|
||||
const actorId = await seedUser('actor-self-suppress', 'Charlie');
|
||||
const listId = await seedList(actorId, 'Private List');
|
||||
// Actor subscribes, but there are no other accessible members → audience is empty after D-03
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait past the window; actor's subscription must never be dispatched
|
||||
await sleep(WINDOW_MS + 200)
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unrelated user with no access is never dispatched (T-05-14 access scoping)', async () => {
|
||||
const actorId = await seedUser('actor-scope', 'Dave')
|
||||
const memberId = await seedUser('member-scope', 'Eve')
|
||||
const unrelatedId = await seedUser('unrelated-scope', 'Frank')
|
||||
const actorId = await seedUser('actor-scope', 'Dave');
|
||||
const memberId = await seedUser('member-scope', 'Eve');
|
||||
const unrelatedId = await seedUser('unrelated-scope', 'Frank');
|
||||
|
||||
const listId = await seedList(actorId, 'Scoped List')
|
||||
await seedShare(listId, memberId)
|
||||
const listId = await seedList(actorId, 'Scoped List');
|
||||
await seedShare(listId, memberId);
|
||||
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(memberId)
|
||||
await seedSubscription(actorId);
|
||||
await seedSubscription(memberId);
|
||||
// Unrelated user also has a subscription — must never be dispatched
|
||||
await seedSubscription(unrelatedId)
|
||||
await seedSubscription(unrelatedId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait for coalescer + DB queries to settle
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Only member (Eve) should be dispatched; unrelated (Frank) must NOT receive push
|
||||
const [calledSub] = mockDispatch.mock.calls[0]
|
||||
expect(calledSub.userId).toBe(memberId)
|
||||
expect(calledSub.userId).not.toBe(unrelatedId)
|
||||
})
|
||||
const [calledSub] = mockDispatch.mock.calls[0];
|
||||
expect(calledSub.userId).toBe(memberId);
|
||||
expect(calledSub.userId).not.toBe(unrelatedId);
|
||||
});
|
||||
|
||||
it('empty audience (no other members) → no dispatch, no crash', async () => {
|
||||
const actorId = await seedUser('actor-no-other', 'Grace')
|
||||
const listId = await seedList(actorId, 'Solo List')
|
||||
const actorId = await seedUser('actor-no-other', 'Grace');
|
||||
const listId = await seedList(actorId, 'Solo List');
|
||||
// No shares; actor-only list
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
await sleep(WINDOW_MS + 200)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty audience (non-actor member has no subscription) → no dispatch, no crash', async () => {
|
||||
const actorId = await seedUser('actor-no-sub', 'Heidi')
|
||||
const otherId = await seedUser('other-no-sub', 'Ivan')
|
||||
const actorId = await seedUser('actor-no-sub', 'Heidi');
|
||||
const otherId = await seedUser('other-no-sub', 'Ivan');
|
||||
|
||||
const listId = await seedList(actorId, 'No Sub List')
|
||||
await seedShare(listId, otherId)
|
||||
const listId = await seedList(actorId, 'No Sub List');
|
||||
await seedShare(listId, otherId);
|
||||
|
||||
// Actor has a subscription, other does NOT
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
// Deliberately no subscription for otherId
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
await sleep(WINDOW_MS + 200)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
// Other has no subscription → no push dispatched
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,84 +10,84 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/listEmitter.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
publishListEvent,
|
||||
subscribeListEvents,
|
||||
type ListEvent,
|
||||
} from '../../src/lib/listEmitter.js'
|
||||
} from '../../src/lib/listEmitter.js';
|
||||
|
||||
describe('listEmitter — scoped fan-out correctness (D-04)', () => {
|
||||
const makeEvent = (listId: number): ListEvent => ({
|
||||
type: 'item:added',
|
||||
listId,
|
||||
payload: { id: 1, text: 'milk', checked: false },
|
||||
})
|
||||
});
|
||||
|
||||
it('publishListEvent delivers to a subscriber of the matching listId (Test 1)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev));
|
||||
|
||||
const ev = makeEvent(1)
|
||||
publishListEvent(1, ev)
|
||||
const ev = makeEvent(1);
|
||||
publishListEvent(1, ev);
|
||||
|
||||
unsub()
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toBe(ev)
|
||||
})
|
||||
unsub();
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toBe(ev);
|
||||
});
|
||||
|
||||
it('publishListEvent does NOT deliver to a subscriber of a different listId (Test 2 — D-04 negative)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev));
|
||||
|
||||
publishListEvent(2, makeEvent(2))
|
||||
publishListEvent(2, makeEvent(2));
|
||||
|
||||
unsub()
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
unsub();
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('the unsubscribe closure stops future delivery (Test 3)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(3, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(3, (ev) => received.push(ev));
|
||||
|
||||
publishListEvent(3, makeEvent(3))
|
||||
unsub()
|
||||
publishListEvent(3, makeEvent(3))
|
||||
publishListEvent(3, makeEvent(3));
|
||||
unsub();
|
||||
publishListEvent(3, makeEvent(3));
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('multiple handlers on the same list channel all receive the event (Test 4)', () => {
|
||||
const calls: number[] = []
|
||||
const unsub1 = subscribeListEvents(4, () => calls.push(1))
|
||||
const unsub2 = subscribeListEvents(4, () => calls.push(2))
|
||||
const unsub3 = subscribeListEvents(4, () => calls.push(3))
|
||||
const calls: number[] = [];
|
||||
const unsub1 = subscribeListEvents(4, () => calls.push(1));
|
||||
const unsub2 = subscribeListEvents(4, () => calls.push(2));
|
||||
const unsub3 = subscribeListEvents(4, () => calls.push(3));
|
||||
|
||||
publishListEvent(4, makeEvent(4))
|
||||
publishListEvent(4, makeEvent(4));
|
||||
|
||||
unsub1()
|
||||
unsub2()
|
||||
unsub3()
|
||||
unsub1();
|
||||
unsub2();
|
||||
unsub3();
|
||||
|
||||
expect(calls).toHaveLength(3)
|
||||
expect(calls).toContain(1)
|
||||
expect(calls).toContain(2)
|
||||
expect(calls).toContain(3)
|
||||
})
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toContain(1);
|
||||
expect(calls).toContain(2);
|
||||
expect(calls).toContain(3);
|
||||
});
|
||||
|
||||
it('emitter handles 100+ concurrent subscribers without MaxListeners error (D-18 scale check)', () => {
|
||||
const N = 120
|
||||
const unsubs: Array<() => void> = []
|
||||
const handler = vi.fn()
|
||||
const N = 120;
|
||||
const unsubs: Array<() => void> = [];
|
||||
const handler = vi.fn();
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
unsubs.push(subscribeListEvents(5, handler))
|
||||
unsubs.push(subscribeListEvents(5, handler));
|
||||
}
|
||||
|
||||
publishListEvent(5, makeEvent(5))
|
||||
publishListEvent(5, makeEvent(5));
|
||||
|
||||
unsubs.forEach((u) => u())
|
||||
unsubs.forEach((u) => u());
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(N)
|
||||
})
|
||||
})
|
||||
expect(handler).toHaveBeenCalledTimes(N);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,67 +10,67 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('pushCoalescer — list-change burst coalescing (D-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('collapses N rapid coalesceListPush calls into a single dispatch with count=N', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
const listId = 1
|
||||
const actorId = 10
|
||||
const N = 5
|
||||
const listId = 1;
|
||||
const actorId = 10;
|
||||
const N = 5;
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
coalesceListPush(listId, actorId, dispatch)
|
||||
coalesceListPush(listId, actorId, dispatch);
|
||||
}
|
||||
|
||||
// Advance time past the coalesce window
|
||||
await vi.runAllTimersAsync()
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
const [calledListId, calledActorId, calledCount] = dispatch.mock.calls[0]
|
||||
expect(calledListId).toBe(listId)
|
||||
expect(calledActorId).toBe(actorId)
|
||||
expect(calledCount).toBe(N)
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [calledListId, calledActorId, calledCount] = dispatch.mock.calls[0];
|
||||
expect(calledListId).toBe(listId);
|
||||
expect(calledActorId).toBe(actorId);
|
||||
expect(calledCount).toBe(N);
|
||||
});
|
||||
|
||||
it('passes the actor userId as excludeUserId so the actor does not notify themselves (D-03)', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
const listId = 2
|
||||
const actorId = 99
|
||||
const listId = 2;
|
||||
const actorId = 99;
|
||||
|
||||
coalesceListPush(listId, actorId, dispatch)
|
||||
await vi.runAllTimersAsync()
|
||||
coalesceListPush(listId, actorId, dispatch);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
const [, calledActorId] = dispatch.mock.calls[0]
|
||||
expect(calledActorId).toBe(actorId)
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [, calledActorId] = dispatch.mock.calls[0];
|
||||
expect(calledActorId).toBe(actorId);
|
||||
});
|
||||
|
||||
it('fires separate dispatches for different lists independently', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
coalesceListPush(1, 10, dispatch)
|
||||
coalesceListPush(1, 10, dispatch)
|
||||
coalesceListPush(2, 10, dispatch) // different list
|
||||
coalesceListPush(2, 10, dispatch)
|
||||
coalesceListPush(1, 10, dispatch);
|
||||
coalesceListPush(1, 10, dispatch);
|
||||
coalesceListPush(2, 10, dispatch); // different list
|
||||
coalesceListPush(2, 10, dispatch);
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Two separate dispatches — one per distinct listId
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { PushSubscription } from '../../src/lib/pushDispatcher.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { PushSubscription } from '../../src/lib/pushDispatcher.js';
|
||||
|
||||
// Mock web-push so no real network calls occur
|
||||
vi.mock('web-push', () => ({
|
||||
@@ -20,7 +20,7 @@ vi.mock('web-push', () => ({
|
||||
sendNotification: vi.fn(),
|
||||
setVapidDetails: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock the DB module so we can assert on deletes without a real database
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
@@ -29,7 +29,7 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
const FAKE_SUB: PushSubscription = {
|
||||
id: 1,
|
||||
@@ -37,60 +37,64 @@ const FAKE_SUB: PushSubscription = {
|
||||
endpoint: 'https://push.example.com/sub/abc',
|
||||
p256dh: 'fake_p256dh',
|
||||
auth: 'fake_auth',
|
||||
}
|
||||
};
|
||||
|
||||
describe('pushDispatcher — 410/404 subscription pruning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes the subscription row when the push service returns 410 (Gone)', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Gone'), { statusCode: 410 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the subscription row when the push service returns 404 (Not Found)', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Not Found'), { statusCode: 404 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delete the row on 201 success', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
vi.mocked(webpush.sendNotification).mockResolvedValueOnce({ statusCode: 201, body: '', headers: {} })
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockResolvedValueOnce({
|
||||
statusCode: 201,
|
||||
body: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delete the row on transient 5xx error', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Service Unavailable'), { statusCode: 503 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,76 +5,76 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/rank.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rankForAppend, rankBetween } from '../../src/lib/rank.js'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankForAppend, rankBetween } from '../../src/lib/rank.js';
|
||||
|
||||
describe('rankForAppend', () => {
|
||||
it('returns "a0" when list is empty (no existing rank)', () => {
|
||||
const rank = rankForAppend(null)
|
||||
expect(rank).toBe('a0')
|
||||
})
|
||||
const rank = rankForAppend(null);
|
||||
expect(rank).toBe('a0');
|
||||
});
|
||||
|
||||
it('returns a rank string that sorts AFTER the given last rank', () => {
|
||||
const lastRank = 'a0'
|
||||
const newRank = rankForAppend(lastRank)
|
||||
expect(newRank > lastRank).toBe(true)
|
||||
})
|
||||
const lastRank = 'a0';
|
||||
const newRank = rankForAppend(lastRank);
|
||||
expect(newRank > lastRank).toBe(true);
|
||||
});
|
||||
|
||||
it('multiple appends produce strictly increasing ranks', () => {
|
||||
let last: string | null = null
|
||||
const ranks: string[] = []
|
||||
let last: string | null = null;
|
||||
const ranks: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = rankForAppend(last)
|
||||
ranks.push(r)
|
||||
last = r
|
||||
const r = rankForAppend(last);
|
||||
ranks.push(r);
|
||||
last = r;
|
||||
}
|
||||
// Each successive rank must be greater than the previous
|
||||
for (let i = 1; i < ranks.length; i++) {
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true)
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankBetween', () => {
|
||||
it('returns "a0" when both prev and next are null (empty list)', () => {
|
||||
const rank = rankBetween(null, null)
|
||||
expect(rank).toBe('a0')
|
||||
})
|
||||
const rank = rankBetween(null, null);
|
||||
expect(rank).toBe('a0');
|
||||
});
|
||||
|
||||
it('returns a rank that sorts between two existing ranks', () => {
|
||||
const first = rankForAppend(null) // 'a0'
|
||||
const second = rankForAppend(first) // 'a1'
|
||||
const between = rankBetween(first, second)
|
||||
expect(between > first).toBe(true)
|
||||
expect(between < second).toBe(true)
|
||||
})
|
||||
const first = rankForAppend(null); // 'a0'
|
||||
const second = rankForAppend(first); // 'a1'
|
||||
const between = rankBetween(first, second);
|
||||
expect(between > first).toBe(true);
|
||||
expect(between < second).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a rank that sorts AFTER prev when next is null', () => {
|
||||
const prev = 'a0'
|
||||
const rank = rankBetween(prev, null)
|
||||
expect(rank > prev).toBe(true)
|
||||
})
|
||||
const prev = 'a0';
|
||||
const rank = rankBetween(prev, null);
|
||||
expect(rank > prev).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a rank that sorts BEFORE next when prev is null', () => {
|
||||
const next = 'a1'
|
||||
const rank = rankBetween(null, next)
|
||||
expect(rank < next).toBe(true)
|
||||
})
|
||||
const next = 'a1';
|
||||
const rank = rankBetween(null, next);
|
||||
expect(rank < next).toBe(true);
|
||||
});
|
||||
|
||||
it('produces lexicographically stable ordering across multiple insertions', () => {
|
||||
// Simulate inserting between 'a0' and 'a1' repeatedly
|
||||
const a = 'a0'
|
||||
const b = 'a1'
|
||||
const c = rankBetween(a, b)
|
||||
const d = rankBetween(a, c)
|
||||
const e = rankBetween(c, b)
|
||||
const a = 'a0';
|
||||
const b = 'a1';
|
||||
const c = rankBetween(a, b);
|
||||
const d = rankBetween(a, c);
|
||||
const e = rankBetween(c, b);
|
||||
|
||||
// All four ranks should be orderable
|
||||
expect(a < d).toBe(true)
|
||||
expect(d < c).toBe(true)
|
||||
expect(c < e).toBe(true)
|
||||
expect(e < b).toBe(true)
|
||||
})
|
||||
expect(a < d).toBe(true);
|
||||
expect(d < c).toBe(true);
|
||||
expect(c < e).toBe(true);
|
||||
expect(e < b).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Precision regression test — LIST-03, Pitfall 2.
|
||||
@@ -86,37 +86,37 @@ describe('rankBetween', () => {
|
||||
* length instead.
|
||||
*/
|
||||
it('repeated mid-point inserts produce unique strictly-increasing ranks over 100 iterations (precision — Pitfall 2)', () => {
|
||||
const ranks: string[] = [rankBetween(null, null), rankBetween(null, null)]
|
||||
const ranks: string[] = [rankBetween(null, null), rankBetween(null, null)];
|
||||
// Set up: two items with known ranks
|
||||
ranks[0] = rankBetween(null, null) // 'a0'
|
||||
ranks[1] = rankBetween(ranks[0], null) // 'a1'
|
||||
ranks[0] = rankBetween(null, null); // 'a0'
|
||||
ranks[1] = rankBetween(ranks[0], null); // 'a1'
|
||||
|
||||
// Insert 100 times between the first item and the second item
|
||||
// This is the worst-case "zipper" pattern — always inserting at the same gap
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const newRank = rankBetween(ranks[0], ranks[1])
|
||||
const newRank = rankBetween(ranks[0], ranks[1]);
|
||||
// Must be strictly between
|
||||
expect(newRank > ranks[0]).toBe(true)
|
||||
expect(newRank < ranks[1]).toBe(true)
|
||||
expect(newRank > ranks[0]).toBe(true);
|
||||
expect(newRank < ranks[1]).toBe(true);
|
||||
// Must be a non-empty string
|
||||
expect(newRank.length).toBeGreaterThan(0)
|
||||
expect(newRank.length).toBeGreaterThan(0);
|
||||
// Must be unique (not equal to any existing rank)
|
||||
expect(ranks).not.toContain(newRank)
|
||||
expect(ranks).not.toContain(newRank);
|
||||
// New item goes at index 1 (after first, before old second) — shift old items
|
||||
ranks.splice(1, 0, newRank)
|
||||
ranks.splice(1, 0, newRank);
|
||||
}
|
||||
|
||||
// Verify the final list is fully sorted ASC
|
||||
for (let i = 1; i < ranks.length; i++) {
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true)
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('rank between two neighbors is strictly between them (D-13 reorder contract)', () => {
|
||||
const prev = 'a0'
|
||||
const next = 'a3'
|
||||
const middle = rankBetween(prev, next)
|
||||
expect(middle > prev).toBe(true)
|
||||
expect(middle < next).toBe(true)
|
||||
})
|
||||
})
|
||||
const prev = 'a0';
|
||||
const next = 'a3';
|
||||
const middle = rankBetween(prev, next);
|
||||
expect(middle > prev).toBe(true);
|
||||
expect(middle < next).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user