mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
5f77fa5cca
Trip invite links (#1143): each trip can have one rotating invite link in its Share panel. An existing, logged-in user who opens /join/<token> is added to the trip as a member; an anonymous visitor is sent to the login page and returned to the invite afterwards — there is no registration from this link. Reading, rotating or disabling the link all require the share_manage permission. Admin invite trip binding (#1402): the admin create-invite dialog can now bind a registration invite to a trip. Someone who registers via that link is auto-added to the trip as a member (password and OIDC paths), inside the same atomic step that consumes the invite. Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared owner-safe/idempotent add-by-id helper, the manage + join endpoints, the JoinTripPage and Share-panel section, the admin trip picker, and the new i18n keys across every locale. Wiki updated. Discussion #1143
93 lines
3.8 KiB
TypeScript
93 lines
3.8 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|