/** * 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 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 { db } from '../../src/db/database'; import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity'; import { Collections } from '../../src/db/entities/Collections.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { CollectionsModule } from '../../src/nest/collections/collections.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { createUser, createTrip, createCategory, createDay, createPlace, createDayAssignment, } from '../helpers/factories'; import { countRows, findRow } from '../helpers/factories/rows'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import request from 'supertest'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, isOwner: () => false, }; }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); let orm: TestOrm; describe('Collections e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; let app: Awaited>; let ownerId: number; let otherId: number; let tripId: number; async function build() { const moduleRef = await Test.createTestingModule({ imports: [ await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), 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 () => { orm = await createTestOrm(db); 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(); await orm.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 = await findRow(orm, Places, { trip: tripId, name: 'Trevi Fountain' }); expect(placed?.reservation_status).toBe('none'); // itinerary defaults }); // #2483: a place from the TREK index can carry its website without a scheme, // and the save dialog has no website field to correct it in. it('COLLECTIONS-E2E-089: saving a place whose website has no scheme stores it as https', async () => { const col = ( await request(server) .post('/api/addons/collections') .set('Cookie', sessionCookie(ownerId)) .send({ name: 'Bretagne' }) ).body; const saved = await request(server) .post('/api/addons/collections/places') .set('Cookie', sessionCookie(ownerId)) .send({ collection_id: col.id, name: 'Chapelle Sainte-Barbe', lat: 48.0286, lng: -3.4862, osm_id: 'gers:ceba0e62-172b-4343-bb3b-78b915a18383', website: 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët', }); expect(saved.status).toBe(200); expect(saved.body.place.website).toBe('https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët'); expect((await findRow(orm, CollectionPlaces, { id: saved.body.place.id }))?.website).toBe( 'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët', ); }); it('COLLECTIONS-E2E-090: a script link on save is still a 400 and stores nothing', async () => { const col = ( await request(server) .post('/api/addons/collections') .set('Cookie', sessionCookie(ownerId)) .send({ name: 'Script links' }) ).body; for (const website of ['javascript:alert(1)', 'mailto:mairie@example.fr', 'Chapelle']) { const res = await request(server) .post('/api/addons/collections/places') .set('Cookie', sessionCookie(ownerId)) .send({ collection_id: col.id, name: 'Chapelle', website }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/^website: /); } expect(await countRows(orm, CollectionPlaces, { collection: col.id })).toBe(0); }); // 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((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('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((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('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((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('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((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('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((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBeNull(); }); // ── 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.3 }); // 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 = ']>' + '&x;'; 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: '' })).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); }); // ── move out of a shared list ──────────────────────────────────────────── // Moving a place out removes it from the source list, so it takes the same // role there as deleting it: an editor is refused, an admin is not. it('COLLECTIONS-E2E-051: moving a place out of a shared list needs admin, not editor', async () => { const shared = ( await request(server) .post('/api/addons/collections') .set('Cookie', sessionCookie(ownerId)) .send({ name: 'Shared moves' }) ).body; const editorId = createUser(db as never).user.id; const adminId = createUser(db as never).user.id; const join = async (userId: number, role: 'editor' | 'admin') => { await request(server) .post('/api/addons/collections/invite') .set('Cookie', sessionCookie(ownerId)) .send({ collection_id: shared.id, user_id: userId, role }); await request(server) .post('/api/addons/collections/invite/accept') .set('Cookie', sessionCookie(userId)) .send({ collection_id: shared.id }); }; await join(editorId, 'editor'); await join(adminId, 'admin'); const place = ( await request(server) .post('/api/addons/collections/places') .set('Cookie', sessionCookie(ownerId)) .send({ collection_id: shared.id, name: 'Pergamon' }) ).body.place; const editorList = ( await request(server) .post('/api/addons/collections') .set('Cookie', sessionCookie(editorId)) .send({ name: 'Editor own' }) ).body; const adminList = ( await request(server) .post('/api/addons/collections') .set('Cookie', sessionCookie(adminId)) .send({ name: 'Admin own' }) ).body; const refused = await request(server) .patch(`/api/addons/collections/places/${place.id}`) .set('Cookie', sessionCookie(editorId)) .send({ collection_id: editorList.id }); expect(refused.status).toBe(403); expect(refused.body.error).toBe('Only an admin can move places out of this list'); expect((await findRow(orm, CollectionPlaces, { id: place.id }))?.collection_id).toBe(shared.id); const moved = await request(server) .patch(`/api/addons/collections/places/${place.id}`) .set('Cookie', sessionCookie(adminId)) .send({ collection_id: adminList.id }); expect(moved.status).toBe(200); expect((await findRow(orm, CollectionPlaces, { id: place.id }))?.collection_id).toBe(adminList.id); }); // ── 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(await countRows(orm, Collections, { id: col.id })).toBe(0); }); });