Files
TREK/server/tests/unit/nest/trip-members.controller.test.ts
T
Maurice 1b58acc78f fix(trips): deleting a guest keeps the costs adding up instead of dropping their payments
A guest's payer rows and recorded payments hung on the users row by ON DELETE
CASCADE, so deleting the guest wiped money that had really moved: expenses
they paid were left without a payer, settled payments reopened, and a custom
or receipt split lost the guest's share so the balances no longer summed to
zero.

Deleting a guest who paid for an expense or is part of a recorded payment is
now refused with a 409 (REST and the delete_trip_guest MCP tool, same text),
and the share dialog says to change the payer or delete the payment first.
Otherwise the guest's share is handed on: an equal split re-splits as before,
a custom amount goes to the others by largest remainder in the currency's
smallest step (members without an amount get one, so the expense stays a
custom split), and a receipt drops the guest from its lines and is split
again like the cost editor does. A legacy paid_by_user_id is cleared as on
account deletion. Account deletion goes through the same re-split. The
confirmation text in all languages and the wiki say what really happens.

Refs #2604
2026-10-12 16:39:17 +02:00

276 lines
12 KiB
TypeScript

import type { AuditService } from '../../../src/nest/audit/audit.service';
import { GUEST_HAS_PAYMENTS } from '../../../src/nest/budget/budget-departure.helpers';
import { DomainError } from '../../../src/nest/common/domain-error';
import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors';
import { TripMembersController } from '../../../src/nest/trip-members/trip-members.controller';
import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module';
import type { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import type { User } from '../../../src/types';
import { expectRegisteredController } from '../../helpers/module-providers';
import { HttpException } from '@nestjs/common';
import { tripTransferOwnershipRequestSchema } from '@trek/shared';
import type { Request } from 'express';
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
/**
* The member/guest routes, moved out of trips.controller.test.ts with the
* controller they cover. Every assertion is unchanged: the routes kept their
* paths, their status codes and their error bodies.
*/
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const req = { headers: {} } as Request;
const writeAudit = vi.fn();
const audit = { writeAudit } as unknown as AuditService;
const tc = (s: TripMembersService) => new TripMembersController(s, audit);
function svc(o: Partial<TripMembersService> = {}): TripMembersService {
return {
canAccessTrip: vi.fn().mockResolvedValue({ user_id: 1 }),
can: vi.fn().mockReturnValue(true),
broadcast: vi.fn(),
notifyInvite: vi.fn(),
getTripForViewer: vi.fn().mockReturnValue({ id: 9 }),
...o,
} as unknown as TripMembersService;
}
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('TripMembersController', () => {
describe('members', () => {
it('GET 404 without access, else owner+members+current_user_id', async () => {
expect(
await thrown(() => tc(svc({ canAccessTrip: vi.fn().mockResolvedValue(undefined) })).members(user, '9')),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
const s = svc({
listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }),
} as Partial<TripMembersService>);
expect(await tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 });
});
it('POST 403 without member_manage, else adds + notifies', async () => {
expect(
await thrown(() =>
tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', { identifier: 'bob@x.y' }),
),
).toEqual({ status: 403, body: { error: 'No permission to manage members' } });
const addMember = vi
.fn()
.mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' });
const notifyInvite = vi.fn();
const s = svc({ addMember, notifyInvite } as Partial<TripMembersService>);
expect(await tc(s).addMember(user, '9', { identifier: 'bob@x.y' })).toEqual({
member: { id: 2, email: 'bob@x.y' },
});
expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y');
// Task 6 review, L1: the pre-existing REST↔MCP asymmetry (§13b) — these five
// controller mutations broadcast NOTHING while the MCP twins all emit
// member:added/member:removed. Pinned here rather than "fixed".
expect(s.broadcast).not.toHaveBeenCalled();
});
it('POST 404 without trip access', async () => {
const s = svc({ canAccessTrip: vi.fn().mockResolvedValue(undefined) });
expect(await thrown(() => tc(s).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({
status: 404,
body: { error: 'Trip not found' },
});
});
it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', async () => {
const nf = svc({
addMember: vi.fn().mockImplementation(() => {
throw new NotFoundError('no user');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(nf).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({
status: 404,
body: { error: 'no user' },
});
const ve = svc({
addMember: vi.fn().mockImplementation(() => {
throw new ValidationError('already a member');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(ve).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({
status: 400,
body: { error: 'already a member' },
});
const other = svc({
addMember: vi.fn().mockImplementation(() => {
throw new Error('boom');
}),
} as Partial<TripMembersService>);
await expect(tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).rejects.toThrow('boom');
});
it('DELETE 404 without trip access', async () => {
const s = svc({ canAccessTrip: vi.fn().mockResolvedValue(undefined) });
expect(await thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({
status: 404,
body: { error: 'Trip not found' },
});
});
it('DELETE self needs no permission; removing others needs member_manage', async () => {
const removeMember = vi.fn();
const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial<TripMembersService>);
// self-removal (targetId === user.id) bypasses the permission check
expect(await tc(s).removeMember(user, '9', '1')).toEqual({ success: true });
expect(await thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({
status: 403,
body: { error: 'No permission to remove members' },
});
// Task 6 review, L1: see the addMember case above.
expect(s.broadcast).not.toHaveBeenCalled();
});
});
describe('POST /:id/transfer (#973)', () => {
// The 404-without-access and 403-for-a-non-owner cases moved to
// trip-owner.guard.test.ts (OWNER-001..010) with the check itself: the route
// carries @RequireTripOwner('Only the owner can transfer ownership') now, and
// a guard runs before the handler this file constructs by hand.
it('a non-numeric newOwnerId 400s in the ZodValidationPipe', () => {
// The hand-rolled 'newOwnerId is required' 400 moved into the global
// pipe (trips DTO ratchet) — the schema rejects non-numeric ids.
expect(tripTransferOwnershipRequestSchema.safeParse({ newOwnerId: 'nope' }).success).toBe(false);
expect(tripTransferOwnershipRequestSchema.safeParse({}).success).toBe(false);
});
it('transfers, audits and broadcasts the refreshed trip', async () => {
const transferOwnership = vi
.fn()
.mockReturnValue({ tripTitle: 'Roadtrip', fromEmail: 'a@x.y', toEmail: 'b@x.y' });
const getTripForViewer = vi.fn().mockReturnValue({ id: 9, user_id: 2 });
const broadcast = vi.fn();
const s = svc({ transferOwnership, getTripForViewer, broadcast } as Partial<TripMembersService>);
expect(await tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req, 'sock')).toEqual({ success: true });
expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id);
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock');
});
it('maps NotFoundError to 404 and ValidationError to 400', async () => {
const nf = svc({
transferOwnership: vi.fn().mockImplementation(() => {
throw new NotFoundError('User not found');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(nf).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({
status: 404,
body: { error: 'User not found' },
});
const ve = svc({
transferOwnership: vi.fn().mockImplementation(() => {
throw new ValidationError('New owner must be a trip member');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(ve).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({
status: 400,
body: { error: 'New owner must be a trip member' },
});
});
});
describe('guests (#1362)', () => {
// Ownership is TripOwnerGuard's job now (OWNER-002 pins the exact message).
it('400 without a name; else creates', async () => {
// A whitespace-only name still 400s with the legacy body — the service
// throws after trimming (the schema only enforces 1..50 chars).
const wsGuest = svc({
createGuest: vi.fn().mockImplementation(() => {
throw new ValidationError('Guest name is required');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({
status: 400,
body: { error: 'Guest name is required' },
});
const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } });
const s = svc({ createGuest } as Partial<TripMembersService>);
expect(await tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({
member: { id: 7, username: 'Anna', is_guest: true },
});
expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id);
// Task 6 review, L1: see the addMember case above.
expect(s.broadcast).not.toHaveBeenCalled();
});
it('rename: 404 when the guest is missing, else success', async () => {
const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial<TripMembersService>);
expect(await thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({
status: 404,
body: { error: 'Guest not found' },
});
const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial<TripMembersService>);
expect(await tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true });
// Task 6 review, L1: see the addMember case above.
expect(ok.broadcast).not.toHaveBeenCalled();
});
it('delete: 404 when the guest is missing, else success', async () => {
const miss = svc({ deleteGuest: vi.fn().mockResolvedValue(false) } as Partial<TripMembersService>);
expect(await thrown(() => tc(miss).deleteGuest(user, '9', '7'))).toEqual({
status: 404,
body: { error: 'Guest not found' },
});
const ok = svc({ deleteGuest: vi.fn().mockResolvedValue(true) } as Partial<TripMembersService>);
expect(await tc(ok).deleteGuest(user, '9', '7')).toEqual({ success: true });
// Task 6 review, L1: see the addMember case above.
expect(ok.broadcast).not.toHaveBeenCalled();
});
it('passes the 409 of a guest who moved money on as it stands, the text the MCP tool answers too (#2604)', async () => {
const refused = svc({
deleteGuest: vi
.fn()
.mockRejectedValue(new DomainError(409, GUEST_HAS_PAYMENTS, { code: 'guest_has_payments' })),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(refused).deleteGuest(user, '9', '7'))).toEqual({
status: 409,
body: { error: GUEST_HAS_PAYMENTS },
});
});
it('maps a ValidationError from createGuest to 400', async () => {
const ve = svc({
createGuest: vi.fn().mockImplementation(() => {
throw new ValidationError('Guest name must be 50 characters or fewer');
}),
} as Partial<TripMembersService>);
expect(await thrown(() => tc(ve).createGuest(user, '9', { name: 'x'.repeat(60) }))).toEqual({
status: 400,
body: { error: 'Guest name must be 50 characters or fewer' },
});
});
});
it('MEMBERS-CTRL-001: the module registers the controller, so the routes cannot go missing', () => {
expectRegisteredController(TripMembersModule, TripMembersController);
expect(Reflect.getMetadata('path', TripMembersController)).toBe('api/trips');
});
});