mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
1b58acc78f
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
276 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|