fix(couples): protect joined invites from cancellation

This commit is contained in:
jjbrunton committed 2026-09-06 12:54:40 +01:00
1 parent a02e9d531b
commit 1fed348072
10 files changed
+139 -2

No files matched your search

@@ -68,6 +68,38 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
it('cancels a waiting invite only for its creator', async () => {
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
await repository.create(charlie, coupleId, 'WAIT2345');
await repository.cancelInvite(charlie);
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
});
it('never deletes a joined couple through the invite cancellation path', async () => {
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await repository.create(alice, coupleId, 'SAFE2345');
await repository.join(bob, 'SAFE2345');
const before = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
code: 'invite_already_joined',
status: 409,
});
const after = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
expect(after.rows).toEqual(before.rows);
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+26
View File
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
import { CoupleService } from '../src/domains/couples/service.js';
import { CoupleError } from '../src/domains/couples/types.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
@@ -25,6 +26,7 @@ function setup() {
invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z',
})),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
@@ -80,4 +82,28 @@ describe('couple routes', () => {
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
});
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
expect(repository.cancel).not.toHaveBeenCalled();
});
it('does not turn a joined invite into relationship deletion', async () => {
const { app, repository } = setup();
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
);
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
});
expect(repository.cancel).not.toHaveBeenCalled();
});
});
+10
View File
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple),
join: vi.fn(async () => couple),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
...overrides,
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
});
it('uses the invite-only cancellation operation for a waiting creator', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
expect(repo.cancel).not.toHaveBeenCalled();
});
});