mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
f4533fc69b
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.
160 lines
6.6 KiB
TypeScript
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();
|
|
});
|
|
});
|