Files
TREK/server/tests/unit/utils/json-column.test.ts
T
Maurice f4533fc69b feat(tours): save_tour_change saves a confirmed change to a city walk whole or not at all
An assistant can now save the change prepare_tour_change prepared, once the
traveller said yes to it exactly as shown. Until now a proposal could only
be looked at; the traveller had to redo it by hand in the planner.

- The save names the proposal: the same changes, the revision it was
  prepared on, the proposal_id the preparation answered and confirmed true.
  Anything else, a change altered on the way or no yes, is refused and
  nothing is saved.
- Place editing on the trip and the tour are checked again before anything
  is written. A tour changed since the proposal was prepared is a conflict
  on the tour's one revision (no second one), telling the assistant to
  prepare the change anew instead of writing over newer edits. The changes
  are laid on the saved tour once more inside the write, so a station
  visited or skipped meanwhile, or a place of the trip gone, refuses the
  save with every reason.
- The change goes through the write the planner's save uses, in one
  transaction: line, stations, keys, measures and visits together or not at
  all, and the trip is told only after the commit. Stations kept keep their
  keys and visits. Where the router gives no way on foot the walk keeps its
  stations only and waits for the planner, as a created one does.
- Every save is kept as a step of the tour (tour_change_steps): the
  proposal, the revision it was prepared on and the one it raised the tour
  to, the changes and the tour as it was before them, so the step can be
  followed and taken back later. Nothing of the conversation is kept.
- A new walk has no revision to name and stays with create_city_walk; both
  tool descriptions and the wiki say which is which.
2026-10-20 15:14:29 +02:00

160 lines
6.6 KiB
TypeScript

import {
COLLAB_POLL_OPTIONS,
JOURNEY_ENTRY_PROS_CONS,
JOURNEY_ENTRY_TAGS,
MCP_TOKEN_API_SCOPES,
OAUTH_CLIENT_ALLOWED_SCOPES,
OAUTH_CLIENT_REDIRECT_URIS,
OAUTH_CONSENT_SCOPES,
OAUTH_TOKEN_SCOPES,
RESERVATION_METADATA,
TOUR_CHANGE_STEP_BEFORE,
TOUR_CHANGE_STEP_CHANGES,
USER_MFA_BACKUP_CODES,
type TourChangeStepBefore,
} from '../../../src/db/json-columns';
import {
decodeJson,
decodeJsonResult,
encodeJson,
logJsonFailure,
type JsonColumn,
} from '../../../src/utils/json-column';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
const LIST: JsonColumn<string[]> = { column: 't.list', schema: z.array(z.string()), fallback: () => [] };
beforeEach(() => vi.clearAllMocks());
describe('decodeJsonResult', () => {
it('JSONCOL-001: decodes text of the right shape', () => {
expect(decodeJsonResult(LIST, '["a","b"]')).toEqual({ ok: true, value: ['a', 'b'] });
});
it('JSONCOL-002: names why a value did not decode', () => {
expect(decodeJsonResult(LIST, null)).toEqual({ ok: false, reason: 'empty' });
expect(decodeJsonResult(LIST, undefined)).toEqual({ ok: false, reason: 'empty' });
expect(decodeJsonResult(LIST, '')).toEqual({ ok: false, reason: 'empty' });
expect(decodeJsonResult(LIST, '{not json')).toEqual({ ok: false, reason: 'invalid-json' });
expect(decodeJsonResult(LIST, '{"a":1}')).toEqual({ ok: false, reason: 'schema' });
});
it('JSONCOL-003: checks a value a driver handed back already parsed', () => {
expect(decodeJsonResult(LIST, ['x'])).toEqual({ ok: true, value: ['x'] });
expect(decodeJsonResult(LIST, { a: 1 })).toEqual({ ok: false, reason: 'schema' });
});
it('JSONCOL-004: unwraps one level of double encoding only where the column says so', () => {
const doubled = JSON.stringify(JSON.stringify({ price: '12' }));
expect(decodeJsonResult(RESERVATION_METADATA, doubled)).toEqual({ ok: true, value: { price: '12' } });
expect(decodeJsonResult(LIST, JSON.stringify(JSON.stringify(['a'])))).toEqual({ ok: false, reason: 'schema' });
expect(decodeJsonResult(RESERVATION_METADATA, JSON.stringify('{broken'))).toEqual({
ok: false,
reason: 'invalid-json',
});
});
});
describe('decodeJson', () => {
it('JSONCOL-010: returns the decoded value without logging', () => {
expect(decodeJson(LIST, '["a"]')).toEqual(['a']);
expect(logMock.logWarn).not.toHaveBeenCalled();
});
it('JSONCOL-011: an empty column falls back quietly', () => {
expect(decodeJson(LIST, null)).toEqual([]);
expect(logMock.logWarn).not.toHaveBeenCalled();
});
it('JSONCOL-012: broken text and the wrong shape fall back and are logged with the column and context', () => {
expect(decodeJson(LIST, '{not json', 'row 7')).toEqual([]);
expect(logMock.logWarn).toHaveBeenLastCalledWith(
'[json] t.list (row 7): stored value is not valid JSON, using the fallback',
);
expect(decodeJson(LIST, '{"a":1}')).toEqual([]);
expect(logMock.logWarn).toHaveBeenLastCalledWith(
'[json] t.list: stored value has the wrong shape, using the fallback',
);
});
it('JSONCOL-013: every fallback is a fresh value', () => {
const a = decodeJson(RESERVATION_METADATA, null);
a.price = '1';
expect(decodeJson(RESERVATION_METADATA, null)).toEqual({});
});
});
describe('logJsonFailure / encodeJson', () => {
it('JSONCOL-020: logs the column, the context and the reason', () => {
logJsonFailure(LIST, 'schema');
expect(logMock.logWarn).toHaveBeenCalledWith('[json] t.list: stored value has the wrong shape, using the fallback');
});
it('JSONCOL-021: encodes a value of the right shape and refuses another', () => {
expect(encodeJson(LIST, ['a', 'b'])).toBe('["a","b"]');
expect(() => encodeJson(LIST, [1] as unknown as string[])).toThrow();
});
});
describe('the declared columns', () => {
it('JSONCOL-030: reservation metadata is an object, and anything else reads as an empty one', () => {
expect(decodeJson(RESERVATION_METADATA, '{"airline":"LH","legs":[1,2]}')).toEqual({ airline: 'LH', legs: [1, 2] });
expect(decodeJson(RESERVATION_METADATA, '[1,2]')).toEqual({});
expect(decodeJson(RESERVATION_METADATA, 'null')).toEqual({});
expect(decodeJson(RESERVATION_METADATA, '{oops')).toEqual({});
});
it('JSONCOL-031: the OAuth lists fail closed to an empty list', () => {
for (const col of [
OAUTH_TOKEN_SCOPES,
OAUTH_CLIENT_ALLOWED_SCOPES,
OAUTH_CLIENT_REDIRECT_URIS,
OAUTH_CONSENT_SCOPES,
]) {
expect(decodeJson(col, '["trips:read"]')).toEqual(['trips:read']);
expect(decodeJson(col, 'garbage')).toEqual([]);
expect(decodeJson(col, '[1]')).toEqual([]);
}
});
it('JSONCOL-032: the lenient lists keep whatever list is stored', () => {
expect(decodeJson(MCP_TOKEN_API_SCOPES, '["a",1]')).toEqual(['a', 1]);
expect(decodeJson(USER_MFA_BACKUP_CODES, '["h1",null]')).toEqual(['h1', null]);
expect(decodeJson(COLLAB_POLL_OPTIONS, '["Yes",{"label":"No"}]')).toEqual(['Yes', { label: 'No' }]);
expect(decodeJson(COLLAB_POLL_OPTIONS, '{"a":1}')).toEqual([]);
});
it('JSONCOL-033: journey entry tags and pros/cons', () => {
expect(decodeJson(JOURNEY_ENTRY_TAGS, '["beach"]')).toEqual(['beach']);
expect(decodeJson(JOURNEY_ENTRY_TAGS, '{bad')).toEqual([]);
expect(decodeJson(JOURNEY_ENTRY_PROS_CONS, '{"pros":["sun"],"cons":[]}')).toEqual({ pros: ['sun'], cons: [] });
expect(decodeJson(JOURNEY_ENTRY_PROS_CONS, 'null')).toBeNull();
expect(decodeJson(JOURNEY_ENTRY_PROS_CONS, '{bad')).toBeNull();
});
it('JSONCOL-034: a saved tour change keeps its changes and the tour before, a broken row claims neither', () => {
const changes = [{ op: 'remove', key: 'stop-park' }];
expect(decodeJson(TOUR_CHANGE_STEP_CHANGES, JSON.stringify(changes))).toEqual(changes);
expect(decodeJson(TOUR_CHANGE_STEP_CHANGES, '[{"op":"rename"}]')).toEqual([]);
const before: TourChangeStepBefore = {
route_geometry: [
[48.1, 11.5, 520],
[48.2, 11.6],
],
waypoints: [{ lat: 48.1, lng: 11.5, role: 'start', sequence: 0, key: 'stop-gate', name: 'Gate' }],
distance: 1.6,
duration: 22,
elevation_gain: null,
elevation_loss: null,
};
expect(decodeJson(TOUR_CHANGE_STEP_BEFORE, encodeJson(TOUR_CHANGE_STEP_BEFORE, before))).toEqual(before);
expect(decodeJson(TOUR_CHANGE_STEP_BEFORE, '{"waypoints":"none"}')).toBeNull();
expect(decodeJson(TOUR_CHANGE_STEP_BEFORE, '{bad')).toBeNull();
});
});