Files
TREK/server/tests/unit/nest/trip-invite.service.test.ts
T
Maurice 8eaeed07a8 test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports,
idempotency, invites, tokens, OAuth, push subscriptions, the version
check, budget settlement maths, addons, place shadow picks, tour GPX
import, the place region repair, journey stats, day notes and storage
jobs seeded and inspected their rows with raw statements. They now go
through the ORM factories and readers over the suite's shared test ORM.

The database mocks of day notes and OAuth also lose their dead
canAccessTrip and isOwner stubs: db/database exports neither since the
repositories took them over.

Raw statements stay, marked, where they are the subject: the backup
file the boot restore reads before any ORM exists, the full raw row a
day note is checked against, sqlite_master, the synchronous probe that
counts tours while the GPX import may still hold its transaction, and
the edits the region repair case lands mid-pass, past the ORM's
connection queue.
2026-10-08 18:45:56 +02:00

128 lines
5.9 KiB
TypeScript

/**
* 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 ──────────────────────────────────────────────────────────────────
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
};
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTripInviteTokensRepo } from '../../helpers/test-uow';
import { sharedTestOrm } from '../../helpers/test-uow';
import { countRows, updateRows } from '../../helpers/factories/rows';
import { TripInviteTokens } from '../../../src/db/entities/TripInviteTokens.entity';
let svc: TripInviteService;
beforeAll(async () => {
const uow = await createTestUnitOfWork(testDb);
svc = new TripInviteService(
// Plan 4 Task 2 — TripInviteService's own canAccessTrip delegate is now
// TripsRepository.findAccessible, in the same constructor slot.
await createTestTripsRepo(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), uow),
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
uow,
await createTestTripInviteTokensRepo(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', async () => {
const { trip } = setup();
expect(await svc.get(trip.id)).toBeNull();
});
it('TRIP-INVITE-002: create returns a token and get reads it back', async () => {
const { owner, trip } = setup();
const info = await svc.createOrRotate(trip.id, owner.id);
expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/);
expect((await svc.get(trip.id))?.token).toBe(info.token);
});
it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', async () => {
const { owner, trip } = setup();
const first = await svc.createOrRotate(trip.id, owner.id);
const second = await svc.createOrRotate(trip.id, owner.id);
expect(second.token).not.toBe(first.token);
const count = await countRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id });
expect(count).toBe(1);
// The old token no longer resolves.
expect(await svc.resolve(first.token)).toBeNull();
});
it('TRIP-INVITE-004: resolve returns the trip for a valid token', async () => {
const { owner, trip } = setup();
const info = await svc.createOrRotate(trip.id, owner.id);
expect(await 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)', async () => {
const { owner, trip } = setup();
const info = await 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.
await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() - 3600_000).toISOString() });
expect(await svc.resolve(info.token)).toBeNull();
});
it('TRIP-INVITE-005b: a not-yet-expired token still resolves', async () => {
const { owner, trip } = setup();
const info = await svc.createOrRotate(trip.id, owner.id);
await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() + 3600_000).toISOString() });
expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
});
it('TRIP-INVITE-006: delete removes the link', async () => {
const { owner, trip } = setup();
const info = await svc.createOrRotate(trip.id, owner.id);
await svc.remove(trip.id);
expect(await svc.get(trip.id)).toBeNull();
expect(await svc.resolve(info.token)).toBeNull();
});
it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', async () => {
const { owner, trip } = setup();
const bounded = await 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((await svc.createOrRotate(trip.id, owner.id, 0)).expires_at).toBeNull();
expect((await svc.createOrRotate(trip.id, owner.id, -3)).expires_at).toBeNull();
});
});