Files
TREK/server/tests/unit/nest/trip-member-writes.service.test.ts
T
Maurice ab02794ff2 fix(notifications): notifications sent from the services name their actor by name
The routes on dev now name the actor of a notification by username instead
of the address. The chat, note, invite and packing notifications the
services send for both surfaces read the name too, so they no longer go out
with an empty actor.
2026-10-22 21:51:44 +00:00

114 lines
4.1 KiB
TypeScript

/**
* TripMemberWritesService over a stubbed roster and budget. MEMBER-WRITES-001+.
*
* The routes and the MCP tools reach these use cases with rows that exist; this
* file pins the two fallbacks they never meet: an inviter whose address cannot
* be read still sends the invitation, and an expense that is gone by the time a
* deleted guest's split is read back again is left out instead of announced.
*/
import { BudgetService } from '../../../src/nest/budget/budget.service';
import type { TripWriter } from '../../../src/nest/common/trip-writer';
import { TripMemberWritesService } from '../../../src/nest/trip-members/trip-member-writes.service';
import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module';
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let writes: TripMemberWritesService;
const roster = {
addMember: vi.fn(),
notifyInvite: vi.fn(),
deleteGuest: vi.fn(),
};
const budget = {
listBudgetItems: vi.fn(),
getBudgetItem: vi.fn(),
};
/** A writer whose user has no row, so nothing about them can be read back. */
function writerFor(userId: number): TripWriter {
return { userId, role: 'user', surface: 'rest', events: { emit: vi.fn(), emitAll: vi.fn() } };
}
beforeAll(async () => {
t = await createTestModule({
db,
imports: [TripMembersModule],
overrides: [
{ provide: TripMembersService, useValue: roster },
{ provide: BudgetService, useValue: budget },
],
});
writes = t.get(TripMemberWritesService);
});
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(async () => {
await t.close();
db.close();
});
describe('TripMemberWritesService', () => {
it('MEMBER-WRITES-001: an inviter whose name cannot be read still sends the invitation, with an empty name', async () => {
const member = { id: 21, email: 'invitee@example.test' };
roster.addMember.mockResolvedValue({ member, targetUserId: 21, tripTitle: 'Lisbon' });
const writer = writerFor(987654);
const result = await writes.addMember(7, 'invitee@example.test', 3, writer);
expect(result.member).toBe(member);
expect(roster.addMember).toHaveBeenCalledWith(7, 'invitee@example.test', 3, 987654);
expect(writer.events.emit).toHaveBeenCalledWith('member:added', { member });
expect(roster.notifyInvite).toHaveBeenCalledWith(
'7',
{ id: 987654, username: '' },
21,
'Lisbon',
'invitee@example.test',
);
});
it('MEMBER-WRITES-002: deleting a guest announces only the expenses that still read back after the re-split', async () => {
const guestId = 40;
budget.listBudgetItems.mockResolvedValue([
{ id: 1, paid_by_user_id: guestId, members: [] },
{ id: 2, paid_by_user_id: null, members: [{ user_id: guestId }] },
{ id: 3, paid_by_user_id: null, members: [{ user_id: 5 }] },
{ id: 4, paid_by_user_id: null },
]);
roster.deleteGuest.mockResolvedValue(true);
budget.getBudgetItem.mockImplementation((itemId: number) => (itemId === 1 ? { id: 1 } : null));
const writer = writerFor(3);
expect(await writes.deleteGuest(7, guestId, writer)).toBe(true);
expect(budget.getBudgetItem.mock.calls).toEqual([
[1, 7],
[2, 7],
]);
expect(vi.mocked(writer.events.emit).mock.calls).toEqual([
['member:removed', { userId: guestId }],
['budget:updated', { item: { id: 1 } }],
]);
});
it('MEMBER-WRITES-003: a guest the roster does not delete reads nothing back and announces nothing', async () => {
budget.listBudgetItems.mockResolvedValue([{ id: 1, paid_by_user_id: 40, members: [] }]);
roster.deleteGuest.mockResolvedValue(false);
const writer = writerFor(3);
expect(await writes.deleteGuest(7, 40, writer)).toBe(false);
expect(budget.getBudgetItem).not.toHaveBeenCalled();
expect(writer.events.emit).not.toHaveBeenCalled();
});
});