Files
TREK/server/tests/e2e/collections.e2e.test.ts
T
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00

501 lines
33 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 DI-native CollectionsService (DatabaseModule + RealtimeModule +
* CollectionsModule) 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, the non-owner 403 on /:id/available-users (no enumeration), and
* a list out as GPX and back in through the reader and the import (#2301).
*/
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/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser, createTrip, createCategory, createDay, createPlace, createDayAssignment } from '../helpers/factories';
import { CollectionsModule } from '../../src/nest/collections/collections.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
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: [DatabaseModule, RealtimeModule, CollectionsModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp().
nest.useGlobalPipes(new ZodValidationPipe());
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
});
// Regression for #1437: editing a place (PATCH without a status field) must NOT
// reset a 'want'/'visited' place back to 'idea'.
it('COLLECTIONS-E2E-012: PATCH without status leaves the saved status unchanged', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Statuses' })).body;
const saved = await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Colosseum', status: 'want' });
expect(saved.status).toBe(200);
const placeId = saved.body.place.id;
expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' });
const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Colosseo' });
expect(patched.status).toBe(200);
expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' });
});
// #1870: the address was missing from the update contract, so the pipe stripped
// it and the column never moved. A saved address was effectively read-only.
it('COLLECTIONS-E2E-013: PATCH address corrects a saved place and survives the validation pipe', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Addresses' })).body;
const saved = await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' });
expect(saved.status).toBe(200);
const placeId = saved.body.place.id;
const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ address: 'Via Nuova 1' });
expect(patched.status).toBe(200);
expect(patched.body.address).toBe('Via Nuova 1');
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
// A rename must not wipe the address that was just corrected.
const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Trattoria da Enzo' });
expect(renamed.status).toBe(200);
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
// null clears it again.
const cleared = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ address: null });
expect(cleared.status).toBe(200);
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: null });
});
// #2471: price, currency, website and phone were stripped by the pipe the same way.
it('COLLECTIONS-E2E-085: PATCH price, currency, website and phone is stored and survives a rename', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Prices' })).body;
const placeId = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id;
const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ price: 12.5, currency: 'chf', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' });
expect(patched.status).toBe(200);
expect(patched.body).toMatchObject({ price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' });
const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Kunsthaus Zürich' });
expect(renamed.status).toBe(200);
expect(db.prepare('SELECT price, currency, website, phone FROM collection_places WHERE id = ?').get(placeId)).toEqual({
price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84',
});
});
it('COLLECTIONS-E2E-086: a negative price, a made-up currency or a script link is a 400 and changes nothing', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Bad prices' })).body;
const placeId = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'EUR' })).body.place.id;
for (const body of [{ price: -1 }, { currency: 'EURO' }, { website: 'javascript:alert(1)' }]) {
const res = await request(server).patch(`/api/addons/collections/places/${placeId}`).set('Cookie', sessionCookie(ownerId)).send(body);
expect(res.status).toBe(400);
}
expect(db.prepare('SELECT price, currency, website FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: 10, currency: 'EUR', website: null });
});
it('COLLECTIONS-E2E-088: a price without a currency on a place that has none is a 400', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Bare prices' })).body;
const placeId = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id;
const res = await request(server).patch(`/api/addons/collections/places/${placeId}`).set('Cookie', sessionCookie(ownerId)).send({ price: 14 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/currency/);
expect(db.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: null, currency: null });
});
it('COLLECTIONS-E2E-087: a member who may only read cannot set a price (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Read only prices' })).body;
await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId, role: 'viewer' });
await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id });
const placeId = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Kunsthaus' })).body.place.id;
const res = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(otherId)).send({ price: 12, currency: 'CHF' });
expect(res.status).toBe(403);
expect(db.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(placeId)).toEqual({ price: null, currency: null });
});
// ── 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
});
// ── Labels ─────────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-060: a label route is addon-gated (404 when disabled)', async () => {
isAddonEnabled.mockReturnValue(false);
expect((await request(server).post('/api/addons/collections/labels').send({ collection_id: 1, name: 'X' })).status).toBe(404);
});
it('COLLECTIONS-E2E-061: create a label, assign it to a place, read it back on the detail', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Germany' })).body;
const place = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Gate' })).body.place;
const label = await request(server).post('/api/addons/collections/labels')
.set('Cookie', sessionCookie(ownerId)).set('X-Socket-Id', 'sock-2').send({ collection_id: col.id, name: 'Berlin', color: '#ff0000' });
expect(label.status).toBe(200);
expect(label.body.name).toBe('Berlin');
const assign = await request(server).post('/api/addons/collections/labels/assign')
.set('Cookie', sessionCookie(ownerId)).send({ label_ids: [label.body.id], place_ids: [place.id] });
expect(assign.status).toBe(200);
expect(assign.body.changed).toBe(1);
const detail = await request(server).get(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId));
expect(detail.body.collection.labels.map((l: { name: string }) => l.name)).toContain('Berlin');
expect(detail.body.places.find((p: { id: number }) => p.id === place.id).label_ids).toContain(label.body.id);
});
it('COLLECTIONS-E2E-062: a stranger cannot create a label on someone else’s list (404)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Secret' })).body;
const res = await request(server).post('/api/addons/collections/labels')
.set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id, name: 'Nope' });
expect(res.status).toBe(404);
});
// ── import preview ───────────────────────────────────────────────────────
it('COLLECTIONS-E2E-070: the preview marks scheduled places and carries the day they sit on', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Rome ideas' })).body;
const trip = createTrip(db as never, ownerId);
const day = createDay(db as never, trip.id, { day_number: 2, date: '2026-05-02' });
const planned = createPlace(db as never, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 });
createPlace(db as never, trip.id, { name: 'Testaccio Market', lat: 41.87, lng: 12.47 });
createDayAssignment(db as never, day.id, planned.id);
const res = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
const byName = Object.fromEntries(res.body.places.map((p: { name: string }) => [p.name, p]));
expect(byName['Colosseum'].scheduled).toBe(true);
expect(byName['Colosseum'].day_number).toBe(2);
expect(byName['Colosseum'].date).toBe('2026-05-02');
// The one no day holds — what the import is for, so the dialog can pre-select it.
expect(byName['Testaccio Market'].scheduled).toBe(false);
expect(byName['Testaccio Market'].day_number).toBeNull();
});
it('COLLECTIONS-E2E-071: the preview verdict is the one the import then acts on', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Paris ideas' })).body;
const trip = createTrip(db as never, ownerId);
const a = createPlace(db as never, trip.id, { name: 'Musée Rodin', lat: 48.85, lng: 2.31 });
const b = createPlace(db as never, trip.id, { name: 'Rue Cler', lat: 48.85, lng: 2.30 });
// Save one of them first, so the list already holds it.
await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Musée Rodin', lat: 48.85, lng: 2.31 });
const preview = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId));
const flagged = preview.body.places.filter((p: { already_in_list: boolean }) => p.already_in_list).map((p: { name: string }) => p.name);
expect(flagged).toEqual(['Musée Rodin']);
// Importing both must skip exactly what the preview greyed out, and copy the rest.
const imported = await request(server).post('/api/addons/collections/places/from-trip-many')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, source_trip_id: trip.id, source_place_ids: [a.id, b.id] });
expect(imported.status).toBe(200);
expect(imported.body.copied).toBe(1);
expect(imported.body.skipped.map((s: { name: string }) => s.name)).toEqual(['Musée Rodin']);
});
// ── bulk visited (#1469) ─────────────────────────────────────────────────
it('COLLECTIONS-E2E-073: a trip selection can be marked visited in every list holding it', async () => {
const paris = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Paris' })).body;
const museums = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Museums' })).body;
const trip = createTrip(db as never, ownerId);
const louvre = createPlace(db as never, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
for (const col of [paris, museums]) {
await request(server).post('/api/addons/collections/places/from-trip')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, source_trip_id: trip.id, source_place_id: louvre.id });
}
const res = await request(server).post('/api/addons/collections/places/status-from-trip')
.set('Cookie', sessionCookie(ownerId)).send({ trip_id: trip.id, place_ids: [louvre.id], status: 'visited' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ updated: 2, places: 1 });
// …and the dialog's per-list view now says so.
const membership = await request(server).get('/api/addons/collections/membership')
.query({ lat: 48.8606, lng: 2.3376 }).set('Cookie', sessionCookie(ownerId));
expect(membership.body.lists.map((l: { status: string }) => l.status)).toEqual(['visited', 'visited']);
});
it('COLLECTIONS-E2E-074: status-many refuses a list the caller may only read', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Shared' })).body;
await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId, role: 'viewer' });
await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id });
const place = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Louvre' })).body.place;
const res = await request(server).post('/api/addons/collections/places/status-many')
.set('Cookie', sessionCookie(otherId)).send({ ids: [place.id], status: 'visited' });
expect(res.status).toBe(403);
});
it('COLLECTIONS-E2E-072: a trip the caller cannot see is a 404, and so is a foreign list', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body;
const foreignTrip = createTrip(db as never, otherId);
expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${foreignTrip.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(404);
expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${tripId}`).set('Cookie', sessionCookie(otherId))).status).toBe(404);
});
// ── GPX (#2301) ──────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-080: a list goes out as GPX and comes back through the reader and the import', async () => {
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const col = (await request(server).post('/api/addons/collections').set(as(ownerId)).send({ name: 'Round trip' })).body;
await request(server).post('/api/addons/collections/places').set(as(ownerId))
.send({ collection_id: col.id, name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' });
await request(server).post('/api/addons/collections/places').set(as(ownerId)).send({ collection_id: col.id, name: 'Vague' });
const exported = await request(server).get(`/api/addons/collections/${col.id}/export/gpx`).set(as(ownerId));
expect(exported.status).toBe(200);
expect(exported.body).toMatchObject({ name: 'Round trip', waypoints: 1, omitted: 1 });
const read = await request(server).post('/api/addons/collections/gpx/read').set(as(otherId))
.send({ gpx: exported.body.gpx, file_name: 'round-trip.gpx' });
expect(read.status).toBe(200);
expect(read.body).toMatchObject({ skipped: 0, track_points: 0 });
const imported = await request(server).post('/api/addons/collections/import').set(as(otherId)).send({ file: read.body.file });
expect(imported.status).toBe(201);
expect(imported.body).toMatchObject({ imported: 1, skipped: 0 });
const detail = await request(server).get(`/api/addons/collections/${imported.body.collection.id}`).set(as(otherId));
expect(detail.body.places).toEqual([expect.objectContaining({ name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' })]);
});
it('COLLECTIONS-E2E-081: a refused GPX says why in a code, a body without one is refused by the contract', async () => {
const hostile = '<?xml version="1.0"?><!DOCTYPE gpx [<!ENTITY x SYSTEM "file:///etc/passwd">]>'
+ '<gpx><wpt lat="1" lon="1"><name>&x;</name></wpt></gpx>';
const refused = await request(server).post('/api/addons/collections/gpx/read').set('Cookie', sessionCookie(ownerId)).send({ gpx: hostile });
expect(refused.status).toBe(400);
expect(refused.body).toEqual({ error: expect.any(String), code: 'unreadable' });
expect((await request(server).post('/api/addons/collections/gpx/read').set('Cookie', sessionCookie(ownerId)).send({})).status).toBe(400);
expect((await request(server).post('/api/addons/collections/gpx/read').send({ gpx: '<gpx/>' })).status).toBe(401);
});
it('COLLECTIONS-E2E-082: a list the caller cannot see has no GPX either', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Mine' })).body;
expect((await request(server).get(`/api/addons/collections/${col.id}/export/gpx`).set('Cookie', sessionCookie(otherId))).status).toBe(404);
});
it('COLLECTIONS-E2E-083: a file goes into a list that already exists, without touching what is in it', async () => {
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const col = (await request(server).post('/api/addons/collections').set(as(ownerId)).send({ name: 'Keep me' })).body;
await request(server).post('/api/addons/collections/places').set(as(ownerId))
.send({ collection_id: col.id, name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' });
const file = {
format: 'trek.collection', version: 1, name: 'From a friend', color: '#ef4444',
places: [{ name: 'Pinned', lat: 41.9, lng: 12.48 }, { name: 'New one' }],
};
const added = await request(server).post(`/api/addons/collections/${col.id}/import`).set(as(ownerId)).send({ file });
expect(added.status).toBe(200);
expect(added.body).toMatchObject({ imported: 1, skipped: 0, duplicates: 1 });
expect(added.body.collection).toMatchObject({ id: col.id, name: 'Keep me' });
const detail = await request(server).get(`/api/addons/collections/${col.id}`).set(as(ownerId));
expect(detail.body.places.map((p: { name: string }) => p.name)).toEqual(['Pinned', 'New one']);
expect(detail.body.places[0]).toMatchObject({ status: 'want' });
});
it('COLLECTIONS-E2E-084: a list the caller may only read, or cannot see at all, takes no file', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Mine' })).body;
const file = { format: 'trek.collection', version: 1, name: 'Theirs', places: [{ name: 'Belém' }] };
expect((await request(server).post(`/api/addons/collections/${col.id}/import`).set('Cookie', sessionCookie(otherId)).send({ file })).status).toBe(404);
expect((await request(server).post(`/api/addons/collections/${col.id}/import`).send({ file })).status).toBe(401);
expect((await request(server).post(`/api/addons/collections/${col.id}/import`).set('Cookie', sessionCookie(ownerId)).send({ file: { name: 'no format' } })).status).toBe(400);
});
// ── 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 });
});
});