From cb0cd3300d3158663e1eb158c5f7d05889e8d3a1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 26 Jul 2026 18:21:04 +0200 Subject: [PATCH] chore: move trip-invite backend to proper DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the legacy tripInviteService SQL into the Nest TripInviteService over the injected DatabaseService (byte-identical statements; the JS-side expiry check and non-positive-means-no-expiry coercion preserved) and delete the legacy module. No bridge, no MCP port and no plugin-host change needed — nothing outside the container consumed this domain. Also wraps the rotation's probe + write + re-select in db.transaction() per the repo rule that multi-statement writes are never bare. Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and adding 007 (expiry pinning); the e2e converted to the DI-native pattern (real SQL against temp trips/trip_invite_tokens tables). --- server/src/nest/README.md | 6 +- server/src/nest/plugins/DI-MIGRATION.md | 6 +- .../nest/trip-invite/trip-invite.service.ts | 94 ++++++++++++-- server/src/services/tripInviteService.ts | 83 ------------ server/tests/e2e/trip-invite.e2e.test.ts | 98 +++++++++----- .../unit/nest/trip-invite.service.test.ts | 120 ++++++++++++++++++ .../unit/services/tripInviteService.test.ts | 92 -------------- 7 files changed, 282 insertions(+), 217 deletions(-) delete mode 100644 server/src/services/tripInviteService.ts create mode 100644 server/tests/unit/nest/trip-invite.service.test.ts delete mode 100644 server/tests/unit/services/tripInviteService.test.ts diff --git a/server/src/nest/README.md b/server/src/nest/README.md index a9ffab7c8..8807cb377 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -104,8 +104,10 @@ gates, and the first `PluginHostDepsFactory` swap done as part of a service migration — no bridge entry for the plugin host); day-notes followed (the first migration needing **no bridge at all**: after its three tools + resource moved to `day-notes.mcp.ts` and the plugin host injection, nothing outside the -container consumed it). Repeat these steps per -service (next up: tripInviteService). This is a **pure relocation** — byte-identical +container consumed it); trip-invite followed (the smallest port: no MCP +surface, no plugin-host import and no bridge — the SQL folded straight into +`trip-invite.service.ts`). Repeat these steps per +service (next up: assignmentService). This is a **pure relocation** — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it injects domain services via `PluginHostDepsFactory`, so a migrated domain adds diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index 07d313045..480b2bcb8 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -99,7 +99,8 @@ factory *until their own domain migrates*. Each future migration then swaps one import for one injected service (+ module export/import) — **no more bridge files for the plugin host, ever**. The factory's import list shrinks monotonically as the recipe in `src/nest/README.md` proceeds (packing and -day-notes swapped in 2026-07; next up: tripInviteService). +day-notes swapped in 2026-07; trip-invite migrated without touching the +factory — it was never imported here; next factory swap: assignmentService). ### Test impact (as landed) @@ -216,7 +217,8 @@ none of it is throwaway. factory, tags/categories bridges deleted, wiring test rewritten against stubs (see §2). 2. **Per domain, as the service migrations proceed** (`packingService` and - `dayNoteService` done 2026-07; `tripInviteService` next, per + `dayNoteService` done 2026-07; `tripInviteService` done 2026-07 with no + factory impact — it was never imported here; `assignmentService` next, per `src/nest/README.md`): migrate the service to DI as usual; the factory swaps one legacy import for one injected service. Optionally pilot `tags.rpc.ts` here — tags is small and diff --git a/server/src/nest/trip-invite/trip-invite.service.ts b/server/src/nest/trip-invite/trip-invite.service.ts index 7fcbb8bb3..7c13244c6 100644 --- a/server/src/nest/trip-invite/trip-invite.service.ts +++ b/server/src/nest/trip-invite/trip-invite.service.ts @@ -1,17 +1,30 @@ import { Injectable } from '@nestjs/common'; +import crypto from 'crypto'; import { DatabaseService } from '../database/database.service'; import type { TripAccess } from '../database/database.service'; import { checkPermission } from '../../services/permissions'; import { joinTripAsMember } from '../../services/tripMembership'; import type { User } from '../../types'; -import * as svc from '../../services/tripInviteService'; type Trip = TripAccess; +export interface TripInviteInfo { + token: string; + expires_at: string | null; + created_at: string; +} + /** - * Thin Nest wrapper around the trip invite-link service. Trip access and the - * 'share_manage' permission mirror the public share link exactly (the invite - * link lives next to it in the Share area). + * Per-trip invite links (#1143) — the legacy tripInviteService SQL folded in + * over the injected DatabaseService (byte-identical statements and coercions). + * + * A trip has at most ONE invite token (mirrors the public share link: one + * rotating token per trip). Unlike the share link, this one is NOT public — + * opening it only does something for a logged-in user with an existing account, + * who is then added to the trip as a member. Rotating or disabling the link + * immediately invalidates the old URL. Trip access and the 'share_manage' + * permission mirror the public share link exactly (the invite link lives next + * to it in the Share area). */ @Injectable() export class TripInviteService { @@ -25,13 +38,76 @@ export class TripInviteService { return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id); } - get(tripId: string) { return svc.getTripInviteLink(tripId); } - createOrRotate(tripId: string, userId: number, expiresInDays?: number | null) { - return svc.createOrRotateTripInviteLink(tripId, userId, expiresInDays ?? null); + /** The current invite link for a trip, or null if none exists. */ + get(tripId: string | number): TripInviteInfo | null { + const row = this.dbs.get<{ token: string; expires_at: string | null; created_at: string }>( + 'SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?', + tripId, + ); + return row ? { token: row.token, expires_at: row.expires_at, created_at: row.created_at } : null; + } + + /** + * Create the trip's invite link, or rotate it to a fresh token (there is only + * ever one row per trip). An optional expiry (days) can bound the link's life. + */ + createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): TripInviteInfo { + const token = crypto.randomBytes(24).toString('base64url'); + // Any non-positive/absent value (0, negatives, NaN, null, undefined) means + // "no expiry" — deliberate: the UI sends null when no bound was chosen. + const expiresAt = + expiresInDays && expiresInDays > 0 + ? new Date(Date.now() + expiresInDays * 86400000).toISOString() + : null; + + // Probe + write + re-select are one atomic unit so a concurrent rotation + // can't interleave between them (the trip_id UNIQUE constraint would turn + // that into a spurious 500). + return this.dbs.transaction(() => { + const existing = this.dbs.get('SELECT id FROM trip_invite_tokens WHERE trip_id = ?', tripId); + if (existing) { + this.dbs.run( + 'UPDATE trip_invite_tokens SET token = ?, expires_at = ?, created_by = ?, created_at = CURRENT_TIMESTAMP WHERE trip_id = ?', + token, expiresAt, createdBy, tripId, + ); + } else { + this.dbs.run( + 'INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)', + tripId, token, createdBy, expiresAt, + ); + } + return this.get(tripId)!; + }); + } + + /** Remove the trip's invite link entirely (disable). */ + remove(tripId: string | number): void { + this.dbs.run('DELETE FROM trip_invite_tokens WHERE trip_id = ?', tripId); + } + + /** + * Resolve an invite token to its (still-existing, unexpired) trip. Returns null + * for unknown/expired tokens. Only ever called for an authenticated user, so the + * trip title is safe to return for the join confirmation screen; an anonymous + * caller never reaches this (the endpoint is JWT-guarded). + */ + resolve(token: string): { trip_id: number; title: string } | null { + const row = this.dbs.get<{ trip_id: number; title: string; expires_at: string | null }>( + `SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at + FROM trip_invite_tokens ti + JOIN trips t ON ti.trip_id = t.id + WHERE ti.token = ?`, + token, + ); + if (!row) return null; + // Check expiry in JS, not SQL: expires_at is stored as an ISO-8601 string + // (…T…Z) which does not compare lexicographically against SQLite's + // space-separated datetime('now'), so a SQL `>` would keep an expired link + // alive until the end of the day. Mirrors the admin-invite validation. + if (row.expires_at && new Date(row.expires_at) < new Date()) return null; + return { trip_id: row.trip_id, title: row.title }; } - remove(tripId: string) { return svc.deleteTripInviteLink(tripId); } - resolve(token: string) { return svc.resolveTripInvite(token); } /** Join the resolved trip as the current (authenticated, non-guest) user. * invited_by is null — they joined via a link, not a personal invite. */ join(tripId: number, userId: number) { return joinTripAsMember(tripId, userId, null); } diff --git a/server/src/services/tripInviteService.ts b/server/src/services/tripInviteService.ts deleted file mode 100644 index 369f95dea..000000000 --- a/server/src/services/tripInviteService.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { db } from '../db/database'; -import crypto from 'crypto'; - -/** - * Per-trip invite links (#1143). - * - * A trip has at most ONE invite token (mirrors the public share link: one - * rotating token per trip). Unlike the share link, this one is NOT public — - * opening it only does something for a logged-in user with an existing account, - * who is then added to the trip as a member. Rotating or disabling the link - * immediately invalidates the old URL. - */ - -export interface TripInviteInfo { - token: string; - expires_at: string | null; - created_at: string; -} - -/** The current invite link for a trip, or null if none exists. */ -export function getTripInviteLink(tripId: string | number): TripInviteInfo | null { - const row = db - .prepare('SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?') - .get(tripId) as { token: string; expires_at: string | null; created_at: string } | undefined; - return row ? { token: row.token, expires_at: row.expires_at, created_at: row.created_at } : null; -} - -/** - * Create the trip's invite link, or rotate it to a fresh token (there is only - * ever one row per trip). An optional expiry (days) can bound the link's life. - */ -export function createOrRotateTripInviteLink( - tripId: string | number, - createdBy: number, - expiresInDays?: number | null, -): TripInviteInfo { - const token = crypto.randomBytes(24).toString('base64url'); - const expiresAt = - expiresInDays && expiresInDays > 0 - ? new Date(Date.now() + expiresInDays * 86400000).toISOString() - : null; - - const existing = db.prepare('SELECT id FROM trip_invite_tokens WHERE trip_id = ?').get(tripId); - if (existing) { - db.prepare( - 'UPDATE trip_invite_tokens SET token = ?, expires_at = ?, created_by = ?, created_at = CURRENT_TIMESTAMP WHERE trip_id = ?', - ).run(token, expiresAt, createdBy, tripId); - } else { - db.prepare( - 'INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)', - ).run(tripId, token, createdBy, expiresAt); - } - return getTripInviteLink(tripId)!; -} - -/** Remove the trip's invite link entirely (disable). */ -export function deleteTripInviteLink(tripId: string | number): void { - db.prepare('DELETE FROM trip_invite_tokens WHERE trip_id = ?').run(tripId); -} - -/** - * Resolve an invite token to its (still-existing, unexpired) trip. Returns null - * for unknown/expired tokens. Only ever called for an authenticated user, so the - * trip title is safe to return for the join confirmation screen; an anonymous - * caller never reaches this (the endpoint is JWT-guarded). - */ -export function resolveTripInvite(token: string): { trip_id: number; title: string } | null { - const row = db - .prepare( - `SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at - FROM trip_invite_tokens ti - JOIN trips t ON ti.trip_id = t.id - WHERE ti.token = ?`, - ) - .get(token) as { trip_id: number; title: string; expires_at: string | null } | undefined; - if (!row) return null; - // Check expiry in JS, not SQL: expires_at is stored as an ISO-8601 string - // (…T…Z) which does not compare lexicographically against SQLite's - // space-separated datetime('now'), so a SQL `>` would keep an expired link - // alive until the end of the day. Mirrors the admin-invite validation. - if (row.expires_at && new Date(row.expires_at) < new Date()) return null; - return { trip_id: row.trip_id, title: row.title }; -} diff --git a/server/tests/e2e/trip-invite.e2e.test.ts b/server/tests/e2e/trip-invite.e2e.test.ts index ea43b5dd2..499287f13 100644 --- a/server/tests/e2e/trip-invite.e2e.test.ts +++ b/server/tests/e2e/trip-invite.e2e.test.ts @@ -1,9 +1,10 @@ /** * Trip invite-link e2e — exercises /api/trips/:tripId/invite-link (manage) and * /api/trip-invites/:token (preview + accept) through the real JwtAuthGuard - * against a temp SQLite db. The invite service, permission check and membership - * join are mocked; this focuses on auth (401), trip-access 404, the share_manage - * 403, the login-required join, and invalid-token 404s (#1143). + * against a temp SQLite db. TripInviteService is DI-native and runs its real + * SQL against the temp db; only the permission check, membership join and + * audit log are mocked. Focuses on auth (401), trip-access 404, the + * share_manage 403, the login-required join, and invalid-token 404s (#1143). */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -13,31 +14,34 @@ import { DatabaseModule } from '../../src/nest/database/database.module'; import { Test } from '@nestjs/testing'; import { seedUser, sessionCookie } from './harness'; -const { db, canAccessTrip } = vi.hoisted(() => { +const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); - return { db: tmp, canAccessTrip: vi.fn() }; + tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);'); + // TripInviteService now runs its real SQL (DI-injected, no mock) — mirror of + // the trip_invite_tokens DDL from migration 153. + tmp.exec(`CREATE TABLE trip_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + expires_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + return { db: tmp }; }); -vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); +const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); +vi.mock('../../src/db/database', () => ({ + db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, +})); const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { inviteSvc } = vi.hoisted(() => ({ - inviteSvc: { - getTripInviteLink: vi.fn(), - createOrRotateTripInviteLink: vi.fn(), - deleteTripInviteLink: vi.fn(), - resolveTripInvite: vi.fn(), - }, -})); -vi.mock('../../src/services/tripInviteService', () => inviteSvc); - const { joinTripAsMember } = vi.hoisted(() => ({ joinTripAsMember: vi.fn() })); vi.mock('../../src/services/tripMembership', () => ({ joinTripAsMember })); @@ -45,6 +49,7 @@ vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { let server: Server; @@ -54,11 +59,20 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripInviteModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); nest.useGlobalFilters(new TrekExceptionFilter()); await nest.init(); return nest; } + function seedTrip(id: number, title: string) { + db.prepare('INSERT INTO trips (id, title) VALUES (?, ?)').run(id, title); + } + function seedToken(tripId: number, token: string, expiresAt: string | null = null) { + db.prepare('INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, 1, ?)') + .run(tripId, token, expiresAt); + } + beforeAll(async () => { seedUser(db as never, { id: 1 }); seedUser(db as never, { id: 2, username: 'e2e-user-2', email: 'e2e-2@example.test' }); @@ -67,11 +81,10 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { }); beforeEach(() => { + db.prepare('DELETE FROM trip_invite_tokens').run(); + db.prepare('DELETE FROM trips').run(); canAccessTrip.mockReturnValue({ user_id: 1 }); checkPermission.mockReturnValue(true); - inviteSvc.getTripInviteLink.mockReset(); - inviteSvc.createOrRotateTripInviteLink.mockReset(); - inviteSvc.resolveTripInvite.mockReset(); joinTripAsMember.mockReset(); }); @@ -83,24 +96,49 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { }); it('GET returns the current link for a trip member', async () => { - inviteSvc.getTripInviteLink.mockReturnValueOnce({ token: 'abc', expires_at: null, created_at: 'now' }); + seedTrip(5, 'Lisbon'); + seedToken(5, 'abc'); const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.token).toBe('abc'); + expect(res.body.expires_at).toBeNull(); }); it('GET returns { token: null } when no link exists', async () => { - inviteSvc.getTripInviteLink.mockReturnValueOnce(null); + seedTrip(5, 'Lisbon'); const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ token: null }); }); it('POST creates/rotates the link', async () => { - inviteSvc.createOrRotateTripInviteLink.mockReturnValueOnce({ token: 'new', expires_at: null, created_at: 'now' }); + seedTrip(5, 'Lisbon'); + seedToken(5, 'old-token'); const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)).send({}); expect([200, 201]).toContain(res.status); - expect(res.body.token).toBe('new'); + expect(res.body.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(res.body.token).not.toBe('old-token'); + const rows = db.prepare('SELECT token FROM trip_invite_tokens WHERE trip_id = 5').all() as { token: string }[]; + expect(rows).toHaveLength(1); + expect(rows[0].token).toBe(res.body.token); + }); + + it('POST with expires_in_days bounds the link life', async () => { + seedTrip(5, 'Lisbon'); + const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)) + .send({ expires_in_days: 7 }); + expect([200, 201]).toContain(res.status); + const expires = new Date(res.body.expires_at).getTime(); + expect(expires).toBeGreaterThan(Date.now() + 6 * 86400000); + expect(expires).toBeLessThan(Date.now() + 8 * 86400000); + }); + + it('POST 400 for a non-numeric expires_in_days string', async () => { + seedTrip(5, 'Lisbon'); + const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)) + .send({ expires_in_days: '7abc' }); + expect(res.status).toBe(400); + expect(db.prepare('SELECT COUNT(*) AS n FROM trip_invite_tokens').get()).toEqual({ n: 0 }); }); it('403 to create without share_manage', async () => { @@ -127,20 +165,23 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { }); it('preview resolves the trip title for an authed user', async () => { - inviteSvc.resolveTripInvite.mockReturnValueOnce({ trip_id: 9, title: 'Rome 2026' }); + seedTrip(9, 'Rome 2026'); + seedToken(9, 'tok'); const res = await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2)); expect(res.status).toBe(200); expect(res.body).toEqual({ trip_id: 9, title: 'Rome 2026' }); }); it('preview 404 for an invalid/expired token', async () => { - inviteSvc.resolveTripInvite.mockReturnValueOnce(null); - const res = await request(server).get('/api/trip-invites/bad').set('Cookie', sessionCookie(2)); - expect(res.status).toBe(404); + seedTrip(9, 'Rome 2026'); + seedToken(9, 'tok', new Date(Date.now() - 3600_000).toISOString()); + expect((await request(server).get('/api/trip-invites/bad').set('Cookie', sessionCookie(2))).status).toBe(404); + expect((await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2))).status).toBe(404); }); it('accept joins the current user and returns the trip id', async () => { - inviteSvc.resolveTripInvite.mockReturnValueOnce({ trip_id: 9, title: 'Rome 2026' }); + seedTrip(9, 'Rome 2026'); + seedToken(9, 'tok'); joinTripAsMember.mockReturnValueOnce({ joined: true, tripId: 9 }); const res = await request(server).post('/api/trip-invites/tok/accept').set('Cookie', sessionCookie(2)); expect(res.status).toBe(200); @@ -149,7 +190,6 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { }); it('accept 404 for an invalid/expired token (no join attempted)', async () => { - inviteSvc.resolveTripInvite.mockReturnValueOnce(null); const res = await request(server).post('/api/trip-invites/bad/accept').set('Cookie', sessionCookie(2)); expect(res.status).toBe(404); expect(joinTripAsMember).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/trip-invite.service.test.ts b/server/tests/unit/nest/trip-invite.service.test.ts new file mode 100644 index 000000000..59672edac --- /dev/null +++ b/server/tests/unit/nest/trip-invite.service.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for the DI-native TripInviteService — TRIP-INVITE-001..006 + * (moved 1:1 from the legacy tests/unit/services/tripInviteService.test.ts). + * Per-trip invite links (#1143): one rotating token, optional expiry, resolve. + * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup ────────────────────────────────────────────────────────────────── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service'; + +const svc = new TripInviteService(new DatabaseService(testDb)); + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => resetTestDb(testDb)); +afterAll(() => testDb.close()); + +function setup() { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + return { owner, trip }; +} + +describe('TripInviteService', () => { + it('TRIP-INVITE-001: no link exists initially', () => { + const { trip } = setup(); + expect(svc.get(trip.id)).toBeNull(); + }); + + it('TRIP-INVITE-002: create returns a token and get reads it back', () => { + const { owner, trip } = setup(); + const info = svc.createOrRotate(trip.id, owner.id); + expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(svc.get(trip.id)?.token).toBe(info.token); + }); + + it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', () => { + const { owner, trip } = setup(); + const first = svc.createOrRotate(trip.id, owner.id); + const second = svc.createOrRotate(trip.id, owner.id); + expect(second.token).not.toBe(first.token); + const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; + expect(count.n).toBe(1); + // The old token no longer resolves. + expect(svc.resolve(first.token)).toBeNull(); + }); + + it('TRIP-INVITE-004: resolve returns the trip for a valid token', () => { + const { owner, trip } = setup(); + const info = svc.createOrRotate(trip.id, owner.id); + expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + }); + + it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', () => { + const { owner, trip } = setup(); + const info = svc.createOrRotate(trip.id, owner.id); + // Use the exact ISO-8601 format the service writes, one hour in the past — + // this catches the lexicographic-SQL-comparison bug where a same-UTC-day + // expiry would otherwise still resolve. + testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') + .run(new Date(Date.now() - 3600_000).toISOString(), trip.id); + expect(svc.resolve(info.token)).toBeNull(); + }); + + it('TRIP-INVITE-005b: a not-yet-expired token still resolves', () => { + const { owner, trip } = setup(); + const info = svc.createOrRotate(trip.id, owner.id); + testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') + .run(new Date(Date.now() + 3600_000).toISOString(), trip.id); + expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + }); + + it('TRIP-INVITE-006: delete removes the link', () => { + const { owner, trip } = setup(); + const info = svc.createOrRotate(trip.id, owner.id); + svc.remove(trip.id); + expect(svc.get(trip.id)).toBeNull(); + expect(svc.resolve(info.token)).toBeNull(); + }); + + it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', () => { + const { owner, trip } = setup(); + const bounded = svc.createOrRotate(trip.id, owner.id, 7); + const expires = new Date(bounded.expires_at!).getTime(); + expect(expires).toBeGreaterThan(Date.now() + 6 * 86400000); + expect(expires).toBeLessThan(Date.now() + 8 * 86400000); + expect(svc.createOrRotate(trip.id, owner.id, 0).expires_at).toBeNull(); + expect(svc.createOrRotate(trip.id, owner.id, -3).expires_at).toBeNull(); + }); +}); diff --git a/server/tests/unit/services/tripInviteService.test.ts b/server/tests/unit/services/tripInviteService.test.ts deleted file mode 100644 index f11f134b9..000000000 --- a/server/tests/unit/services/tripInviteService.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Unit tests for tripInviteService — TRIP-INVITE-001..006. - * Per-trip invite links (#1143): one rotating token, optional expiry, resolve. - */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; - -const { testDb, dbMock } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec('PRAGMA foreign_keys = ON'); - return { testDb: db, dbMock: { db } }; -}); -vi.mock('../../../src/db/database', () => dbMock); - -import { createTables } from '../../../src/db/schema'; -import { runMigrations } from '../../../src/db/migrations'; -import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip } from '../../helpers/factories'; -import { - getTripInviteLink, - createOrRotateTripInviteLink, - deleteTripInviteLink, - resolveTripInvite, -} from '../../../src/services/tripInviteService'; - -beforeAll(() => { createTables(testDb); runMigrations(testDb); }); -beforeEach(() => resetTestDb(testDb)); -afterAll(() => testDb.close()); - -function setup() { - const { user: owner } = createUser(testDb); - const trip = createTrip(testDb, owner.id); - return { owner, trip }; -} - -describe('tripInviteService', () => { - it('TRIP-INVITE-001: no link exists initially', () => { - const { trip } = setup(); - expect(getTripInviteLink(trip.id)).toBeNull(); - }); - - it('TRIP-INVITE-002: create returns a token and get reads it back', () => { - const { owner, trip } = setup(); - const info = createOrRotateTripInviteLink(trip.id, owner.id); - expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); - expect(getTripInviteLink(trip.id)?.token).toBe(info.token); - }); - - it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', () => { - const { owner, trip } = setup(); - const first = createOrRotateTripInviteLink(trip.id, owner.id); - const second = createOrRotateTripInviteLink(trip.id, owner.id); - expect(second.token).not.toBe(first.token); - const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; - expect(count.n).toBe(1); - // The old token no longer resolves. - expect(resolveTripInvite(first.token)).toBeNull(); - }); - - it('TRIP-INVITE-004: resolve returns the trip for a valid token', () => { - const { owner, trip } = setup(); - const info = createOrRotateTripInviteLink(trip.id, owner.id); - expect(resolveTripInvite(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); - }); - - it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', () => { - const { owner, trip } = setup(); - const info = createOrRotateTripInviteLink(trip.id, owner.id); - // Use the exact ISO-8601 format the service writes, one hour in the past — - // this catches the lexicographic-SQL-comparison bug where a same-UTC-day - // expiry would otherwise still resolve. - testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') - .run(new Date(Date.now() - 3600_000).toISOString(), trip.id); - expect(resolveTripInvite(info.token)).toBeNull(); - }); - - it('TRIP-INVITE-005b: a not-yet-expired token still resolves', () => { - const { owner, trip } = setup(); - const info = createOrRotateTripInviteLink(trip.id, owner.id); - testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') - .run(new Date(Date.now() + 3600_000).toISOString(), trip.id); - expect(resolveTripInvite(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); - }); - - it('TRIP-INVITE-006: delete removes the link', () => { - const { owner, trip } = setup(); - const info = createOrRotateTripInviteLink(trip.id, owner.id); - deleteTripInviteLink(trip.id); - expect(getTripInviteLink(trip.id)).toBeNull(); - expect(resolveTripInvite(info.token)).toBeNull(); - }); -});