mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move trip-invite backend to proper DI
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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user