mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
2350c3ae14
A place from the TREK index can carry its website the way Overture has it, such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving it to a collection or a trip failed with "website must be an http or https URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and booking mails hand over websites the same way. Every place a website comes in from outside now goes through one shared helper, normalizePlaceWebsite: the index client and its nearby search, the Overpass and OSM detail mappers, Google search and details, Amap, plugin search and the booking import. It completes a bare or protocol-relative host to https, keeps http and https as they are, and drops every other scheme and anything that is no address. Only the scheme is added, so an accented path is stored as written. placeWebsiteSchema completes a bare host the same way before its check, so an older client, a cached offline result, an MCP agent or a plugin is no longer refused, while javascript:, data: and mailto: still are. The REST controller, the plugin RPC and the enrichment now store the parsed value instead of the raw one, and create_place_accommodation takes the website through the same schema as create_place. safeHttpUrl still links only a value already stored in its final form, so nothing changes on screen. Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034, SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090, PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01, PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010, AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01, BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted, FE-UTIL-SAFEURL-007.
477 lines
30 KiB
TypeScript
477 lines
30 KiB
TypeScript
/**
|
||
* 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
|
||
});
|
||
|
||
// #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(db.prepare('SELECT website FROM collection_places WHERE id = ?').get(saved.body.place.id)).toEqual({
|
||
website: '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(db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 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(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 });
|
||
});
|
||
|
||
// ── 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 });
|
||
});
|
||
});
|