mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a910c2a837
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.
501 lines
33 KiB
TypeScript
501 lines
33 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
|
||
});
|
||
|
||
// 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 });
|
||
});
|
||
});
|