mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection places from0df82185band its review follow-up ina910c2a83. 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 fromf16698bdb, which only mattered for the price field.
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user