mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
178 lines
11 KiB
TypeScript
178 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
|
|
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() }));
|
|
|
|
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 { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors';
|
|
import type { User } from '../../../src/types';
|
|
import { tripTransferOwnershipRequestSchema } from '@trek/shared';
|
|
import { expectRegisteredController } from '../../helpers/module-providers';
|
|
|
|
/**
|
|
* 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');
|
|
});
|
|
});
|