Files
TREK/server/tests/unit/nest/trip-members.controller.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

262 lines
12 KiB
TypeScript

import type { AuditService } from '../../../src/nest/audit/audit.service';
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('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');
});
});