Files
TREK/server/tests/e2e/collections.e2e.test.ts
T
Maurice ec8509f2de feat(collections): backend for the Overall Places addon (#1081)
Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
2026-07-01 18:02:44 +02:00

204 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Collections e2e — drives /api/addons/collections through the REAL JwtAuthGuard
* AND the real collectionsService against a temp SQLite db (full schema). Only the
* addon flag, websocket and notification send are mocked. Covers: the addon gate
* (404 before auth), auth, CRUD happy paths, invite/accept/decline, copy-to-trip,
* cross-user 404s and the non-owner 403 on /:id/available-users (no enumeration).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
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('PRAGMA foreign_keys = ON');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: number | string, userId: number) =>
db.prepare('SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)').get(userId, tripId, userId),
isOwner: () => false,
}));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser, createTrip, createCategory } from '../helpers/factories';
import { CollectionsModule } from '../../src/nest/collections/collections.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Collections e2e (real auth guard + real service + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let ownerId: number;
let otherId: number;
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db as never);
runMigrations(db as never);
ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.example' }).user.id;
otherId = createUser(db as never, { username: 'other', email: 'other@test.example' }).user.id;
createCategory(db as never);
tripId = createTrip(db as never, ownerId).id;
app = await build();
server = app.getHttpServer();
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
// ── Addon gate ───────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-001: addon disabled → 404 before auth (no cookie)', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/addons/collections');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Collections addon is not enabled' });
});
it('COLLECTIONS-E2E-002: addon disabled → 404 on a deep route too', async () => {
isAddonEnabled.mockReturnValue(false);
expect((await request(server).post('/api/addons/collections').send({ name: 'X' })).status).toBe(404);
expect((await request(server).get('/api/addons/collections/1')).status).toBe(404);
});
it('COLLECTIONS-E2E-003: addon enabled but no cookie → 401', async () => {
expect((await request(server).get('/api/addons/collections')).status).toBe(401);
});
// ── CRUD happy path ──────────────────────────────────────────────────────
it('COLLECTIONS-E2E-010: create → list → get a collection', async () => {
const created = await request(server).post('/api/addons/collections')
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Italy' });
expect(created.status).toBe(201);
expect(created.body.name).toBe('Italy');
const id = created.body.id;
const list = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(ownerId));
expect(list.status).toBe(200);
expect(list.body.collections.map((c: { id: number }) => c.id)).toContain(id);
expect(list.body).toHaveProperty('incomingInvites');
const detail = await request(server).get(`/api/addons/collections/${id}`).set('Cookie', sessionCookie(ownerId));
expect(detail.status).toBe(200);
expect(detail.body.collection.id).toBe(id);
expect(detail.body.places).toEqual([]);
});
it('COLLECTIONS-E2E-011: save a place (200) then copy it to a trip', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Trip plan' })).body;
const saved = await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).set('X-Socket-Id', 'sock-1').send({ collection_id: col.id, name: 'Trevi Fountain', lat: 41.9, lng: 12.48 });
expect(saved.status).toBe(200);
expect(saved.body.place.name).toBe('Trevi Fountain');
const copy = await request(server).post('/api/addons/collections/copy-to-trip')
.set('Cookie', sessionCookie(ownerId)).send({ trip_id: tripId, place_ids: [saved.body.place.id] });
expect(copy.status).toBe(200);
expect(copy.body.copied).toBe(1);
const placed = db.prepare("SELECT reservation_status FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(tripId) as { reservation_status: string };
expect(placed.reservation_status).toBe('none'); // itinerary defaults
});
// ── Cross-user isolation ─────────────────────────────────────────────────
it('COLLECTIONS-E2E-020: a stranger gets 404 on someone else’s collection', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body;
const res = await request(server).get(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId));
expect(res.status).toBe(404);
});
// ── Fusion ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-030: invite → accept → member sees the list → decline path', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Shared' })).body;
const invite = await request(server).post('/api/addons/collections/invite')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId });
expect(invite.status).toBe(200);
// recipient sees it as an incoming invite
const inbox = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(otherId));
expect(inbox.body.incomingInvites.map((i: { collection_id: number }) => i.collection_id)).toContain(col.id);
const accept = await request(server).post('/api/addons/collections/invite/accept')
.set('Cookie', sessionCookie(otherId)).set('X-Socket-Id', 'sock-9').send({ collection_id: col.id });
expect(accept.status).toBe(200);
const memberList = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(otherId));
expect(memberList.body.collections.map((c: { id: number }) => c.id)).toContain(col.id);
const leave = await request(server).post('/api/addons/collections/leave')
.set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id });
expect(leave.status).toBe(200);
});
it('COLLECTIONS-E2E-031: invite is owner-only (non-owner → 403, not 404 leak after access)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'OwnerOnly' })).body;
// a complete stranger cannot even see it → 404
const stranger = await request(server).post('/api/addons/collections/invite')
.set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id, user_id: ownerId });
expect(stranger.status).toBe(404);
});
// ── available-users owner guard ──────────────────────────────────────────
it('COLLECTIONS-E2E-040: /:id/available-users — owner 200, stranger 404 (no enumeration)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Members' })).body;
const ok = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(ownerId));
expect(ok.status).toBe(200);
expect(Array.isArray(ok.body.users)).toBe(true);
expect(ok.body.users.map((u: { id: number }) => u.id)).not.toContain(ownerId);
const denied = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(otherId));
expect(denied.status).toBe(404); // not visible → 404 (never reveals existence to a non-member)
});
it('COLLECTIONS-E2E-041: an accepted member (non-owner) hitting available-users gets 403', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Members2' })).body;
await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId });
await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id });
const res = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(otherId));
expect(res.status).toBe(403); // visible (member) but not owner
});
// ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;
await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId });
await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id });
expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId))).status).toBe(403);
expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(200);
expect(db.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 });
});
});