revert(collections): a saved place carries no price, website or phone again

Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
This commit is contained in:
Maurice
2026-09-23 20:32:41 +02:00
parent f16698bdb1
commit a1d6348c5f
40 changed files with 25 additions and 937 deletions
-55
View File
@@ -183,61 +183,6 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
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;
@@ -380,64 +380,6 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col
expect(cleared.place.rating_count).toBe(0);
});
});
// #2471: the tool spreads the REST update contract, so the four fields the
// contract gained arrive here too; the strict shape still refuses the rest.
it('sets price, currency, website and phone, refuses a bad price and still refuses an unknown key', async () => {
const { user } = createUser(testDb);
const col = seedCollection(user.id, 'P');
const pid = seedPlace(col, user.id, 'Pergamon');
await withHarness(user.id, async (h) => {
const upd = parseToolResult(await h.client.callTool({
name: 'update_collection_place',
arguments: { placeId: pid, price: 14, currency: 'eur', website: 'https://pergamon.example', phone: '+49 30 266 42 42 42' },
})) as { place: { price: number; currency: string; website: string; phone: string } };
expect(upd.place).toMatchObject({ price: 14, currency: 'EUR', website: 'https://pergamon.example', phone: '+49 30 266 42 42 42' });
const negative = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, price: -3 } });
expect(negative.isError).toBe(true);
const unknown = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, entry_fee: 14 } });
expect(unknown.isError).toBe(true);
expect(errorText(unknown)).toMatch(/Unrecognized key.*entry_fee/);
const stored = testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(pid);
expect(stored).toEqual({ price: 14, currency: 'EUR' });
});
});
// The contract takes price and currency as separate fields; the service holds
// them together, so an agent cannot leave a bare amount on a place without one.
it('refuses a price without a currency on a place that has none', async () => {
const { user } = createUser(testDb);
const col = seedCollection(user.id, 'P');
const pid = seedPlace(col, user.id, 'Pergamon');
await withHarness(user.id, async (h) => {
const bare = await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, price: 14 } });
expect(bare.isError).toBe(true);
expect(errorText(bare)).toMatch(/currency/);
expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(pid)).toEqual({ price: null, currency: null });
const paired = parseToolResult(await h.client.callTool({
name: 'update_collection_place', arguments: { placeId: pid, price: 14, currency: 'EUR' },
})) as { place: { price: number; currency: string } };
expect(paired.place).toMatchObject({ price: 14, currency: 'EUR' });
});
});
it('describes the price as a rough cost that needs its currency', async () => {
const { user } = createUser(testDb);
const h = await createMcpHarness({ userId: user.id, withResources: false });
try {
const tool = (await h.client.listTools()).tools.find((t) => t.name === 'update_collection_place');
expect(tool?.description).toMatch(/price with currency/);
expect(Object.keys((tool?.inputSchema as { properties: Record<string, unknown> }).properties)).toEqual(
expect.arrayContaining(['price', 'currency', 'website', 'phone']),
);
} finally {
await h.cleanup();
}
});
});
// ---------------------------------------------------------------------------
@@ -6,9 +6,7 @@ import type { CollectionsService } from '../../../src/nest/collections/collectio
import type { StorageService } from '../../../src/nest/storage/storage.service';
import { CollectionGpxError } from '../../../src/nest/collections/collection-gpx.helpers';
import type { User } from '../../../src/types';
import { collectionPlaceUpdateRequestSchema, type CollectionGpxProblem } from '@trek/shared';
import { CollectionPlaceUpdateDto } from '../../../src/nest/collections/collections.dto';
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
import type { CollectionGpxProblem } from '@trek/shared';
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
@@ -112,27 +110,6 @@ describe('CollectionsController', () => {
expect(c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never)).toEqual({ copied: 1, skipped: [] });
});
// #2471: the PATCH body stripped price, currency, website and phone before the
// handler ever saw them. The DTO is the same shape update_collection_place
// spreads, so REST and MCP take the same four fields and refuse the same values.
it('updatePlace keeps price, currency, website and phone through the body pipe and forwards them', () => {
const pipe = new ZodValidationPipe();
const meta = { type: 'body' as const, metatype: CollectionPlaceUpdateDto };
const body = pipe.transform({ price: 12.5, currency: ' chf', website: 'https://kunsthaus.example', phone: '+41 44' }, meta);
expect(body).toEqual({ price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' });
const svc = makeService();
new CollectionsController(svc, new RuntimeEnvService(), storageStub).updatePlace(user, '9', body as never, 'sid');
expect(svc.updatePlace).toHaveBeenCalledWith(1, 9, { price: 12.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' }, 'sid');
expect(thrown(() => pipe.transform({ price: -1 }, meta)).status).toBe(400);
expect(thrown(() => pipe.transform({ currency: 'EURO' }, meta)).status).toBe(400);
expect(Object.keys(collectionPlaceUpdateRequestSchema.shape)).toEqual(
expect.arrayContaining(['price', 'currency', 'website', 'phone']),
);
expect(CollectionPlaceUpdateDto.schema).toBe(collectionPlaceUpdateRequestSchema);
});
// The legacy 'ids must be an array of numbers' 400 is gone:
// collectionDeleteManyRequestSchema types the array, so the pipe rejects a
// bad payload before the handler runs.
@@ -337,86 +337,6 @@ describe('status + updatePlace move', () => {
expect((await svc.updatePlace(u.id, p.id, { name: 'Trattoria da Enzo' })).address).toBe('Via Vechia 1');
});
// #2471: price, currency, website and phone were stored on save and carried into
// a trip on copy, but the UPDATE set never named them, so none could be edited.
it('COLLECTIONS-SVC-128: updatePlace writes price, currency, website and phone', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus' }).place!;
const updated = await svc.updatePlace(u.id, p.id, {
price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84',
});
expect(updated).toMatchObject({ price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84' });
expect(testDb.prepare('SELECT price, currency, website, phone FROM collection_places WHERE id = ?').get(p.id)).toEqual({
price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44 253 84 84',
});
});
it('COLLECTIONS-SVC-129: null clears each of the four', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, {
collection_id: col.id, name: 'Kunsthaus', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44',
}).place!;
const cleared = await svc.updatePlace(u.id, p.id, { price: null, currency: null, website: null, phone: null });
expect(cleared).toMatchObject({ price: null, currency: null, website: null, phone: null });
});
it('COLLECTIONS-SVC-130: an update that leaves them out keeps the stored values', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, {
collection_id: col.id, name: 'Kunsthaus', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44',
}).place!;
const renamed = await svc.updatePlace(u.id, p.id, { name: 'Kunsthaus Zürich' });
expect(renamed).toMatchObject({ name: 'Kunsthaus Zürich', price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' });
});
// A list has no base currency, so a bare amount would be read in every
// viewer's own default currency. The service refuses it for REST and MCP alike.
it('COLLECTIONS-SVC-132: a price without a currency on a place that has none is a 400 and changes nothing', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus' }).place!;
await expect(svc.updatePlace(u.id, p.id, { price: 14 })).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/currency/) });
await expect(svc.updatePlace(u.id, p.id, { price: 14, currency: null })).rejects.toMatchObject({ status: 400 });
expect(testDb.prepare('SELECT price, currency FROM collection_places WHERE id = ?').get(p.id)).toEqual({ price: null, currency: null });
});
it('COLLECTIONS-SVC-133: a price alone keeps the stored currency, a zero price needs none, and a priced place cannot lose its currency', async () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Zurich' });
const priced = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'CHF' }).place!;
const free = svc.savePlace(u.id, { collection_id: col.id, name: 'Lake shore' }).place!;
expect(await svc.updatePlace(u.id, priced.id, { price: 24.5 })).toMatchObject({ price: 24.5, currency: 'CHF' });
expect(await svc.updatePlace(u.id, free.id, { price: 0 })).toMatchObject({ price: 0, currency: null });
await expect(svc.updatePlace(u.id, priced.id, { currency: null })).rejects.toMatchObject({ status: 400 });
expect(await svc.updatePlace(u.id, priced.id, { price: null, currency: null })).toMatchObject({ price: null, currency: null });
});
it('COLLECTIONS-SVC-131: copyToTrip carries the edited price, currency, website and phone into the trip place', async () => {
const u = createUser(testDb).user;
const trip = createTrip(testDb, u.id);
const col = svc.createCollection(u.id, { name: 'Zurich' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Kunsthaus', price: 10, currency: 'EUR' }).place!;
await svc.updatePlace(u.id, p.id, { price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44' });
expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] }).copied).toBe(1);
expect(testDb.prepare("SELECT price, currency, website, phone FROM places WHERE trip_id = ? AND name = 'Kunsthaus'").get(trip.id)).toEqual({
price: 24.5, currency: 'CHF', website: 'https://kunsthaus.example', phone: '+41 44',
});
});
});
// ── copy to trip ─────────────────────────────────────────────────────────────