/** * walker.test — the pure journey builder: strategies per view, per_role * overrides, click_row degradation, write-flow chaining, caps. */ import { describe, it, expect } from 'vitest'; import { PlanTestSchema, type PlanTest } from '../../lib/plantest-schema.js'; import { PLACEHOLDER_ID, buildJourneys, buildRoleJourney, expectedFor, markerFor, substituteParams, viewOfRoute, } from '../walker.js'; function makePlan(over: Record = {}): PlanTest { return PlanTestSchema.parse({ schema_version: '1.0.0', meta: { application: 'administration', path: 'administration' }, roles: ['admin', 'viewer', 'anonymous'], execution: { screenshots: { out_dir: 'shots' }, per_role: { anonymous: { mode: 'goto', expect: 'redirect_login' } }, ...((over.execution as Record) ?? {}), }, routes: [ { id: 'ADMINISTRATION_USERS', component_key: 'administration.users', route: '/administration/users', navigation_strategy: 'menu_click', permission: 'administration.users.read', access: { admin: 'allowed', viewer: 'allowed', anonymous: 'redirect_login' }, }, { id: 'ADMINISTRATION_USERS_DETAIL', component_key: 'administration.users.detail', route: '/administration/users/:id', navigation_strategy: 'click_row_in_parent', parent_route: '/administration/users', on_empty_list: 'INDETERMINATE', access: { admin: 'allowed', viewer: 'allowed', anonymous: 'redirect_login' }, }, { id: 'ADMINISTRATION_USERS_EDIT', component_key: 'administration.users.edit', route: '/administration/users/:id/edit', navigation_strategy: 'click_row_in_parent', parent_route: '/administration/users', access: { admin: 'allowed', viewer: 'denied', anonymous: 'redirect_login' }, }, { id: 'ADMINISTRATION_USERS_CREATE', component_key: 'administration.users.create', route: '/administration/users/create', navigation_strategy: 'goto', access: { admin: 'allowed', viewer: 'denied', anonymous: 'redirect_login' }, }, ], endpoints: [], ...over, }); } describe('pure helpers', () => { it('substituteParams swaps every :param for the placeholder', () => { expect(substituteParams('/a/:id/edit')).toBe(`/a/${PLACEHOLDER_ID}/edit`); expect(substituteParams('/a/b')).toBe('/a/b'); }); it('viewOfRoute classifies create/edit/detail/base', () => { const plan = makePlan(); expect(viewOfRoute(plan.routes[0])).toBe('base'); expect(viewOfRoute(plan.routes[1])).toBe('detail'); expect(viewOfRoute(plan.routes[2])).toBe('edit'); expect(viewOfRoute(plan.routes[3])).toBe('create'); }); it('expectedFor honors per_role overrides over the access map', () => { const plan = makePlan(); expect(expectedFor(plan, plan.routes[0], 'admin')).toBe('allowed'); expect(expectedFor(plan, plan.routes[0], 'anonymous')).toBe('redirect_login'); }); it('markerFor is deterministic and tagged', () => { expect(markerFor('ADMINISTRATION_USERS_CREATE', '123456')).toBe('UAT-ADMINISTRATI-123456'); }); }); describe('permission propagation', () => { it('carries route.permission onto the step (absent when the plan has none)', () => { const journey = buildRoleJourney(makePlan(), 'admin', { writes: false, runTag: 't', maxSteps: 500 }); const list = journey.steps.find((s) => s.routeId === 'ADMINISTRATION_USERS')!; expect(list.permission).toBe('administration.users.read'); const detail = journey.steps.find((s) => s.routeId === 'ADMINISTRATION_USERS_DETAIL')!; expect(detail.permission).toBeUndefined(); }); }); describe('buildRoleJourney', () => { const opts = { writes: true, runTag: 'tag1', maxSteps: 500 }; it('admin: clicks through menu + rows, then chains the write flows after create', () => { const journey = buildRoleJourney(makePlan(), 'admin', opts); expect(journey.anonymous).toBe(false); expect(journey.steps.map((s) => `${s.kind}:${s.routeId}`)).toEqual([ 'page:ADMINISTRATION_USERS', 'page:ADMINISTRATION_USERS_DETAIL', 'page:ADMINISTRATION_USERS_EDIT', 'page:ADMINISTRATION_USERS_CREATE', 'create_flow:ADMINISTRATION_USERS_CREATE', 'edit_flow:ADMINISTRATION_USERS_CREATE', 'delete_flow:ADMINISTRATION_USERS_CREATE', ]); const [list, detail, , create, createFlow] = journey.steps; expect(list.strategy).toBe('menu_click'); expect(detail.strategy).toBe('click_row_in_parent'); expect(detail.url).toBe('/administration/users/:id'); // raw — the click resolves the real id expect(create.strategy).toBe('goto'); expect(createFlow.parentRoute).toBe('/administration/users'); expect(createFlow.marker).toBe(markerFor('ADMINISTRATION_USERS_CREATE', 'tag1')); }); it('viewer: denied edit degrades to a placeholder-id goto; no write flows on a denied create', () => { const journey = buildRoleJourney(makePlan(), 'viewer', opts); const edit = journey.steps.find((s) => s.routeId === 'ADMINISTRATION_USERS_EDIT')!; expect(edit.strategy).toBe('goto'); expect(edit.url).toBe(`/administration/users/${PLACEHOLDER_ID}/edit`); expect(journey.steps.every((s) => s.kind === 'page')).toBe(true); }); it('anonymous: per_role forces goto + redirect_login everywhere, no flows', () => { const journey = buildRoleJourney(makePlan(), 'anonymous', opts); expect(journey.anonymous).toBe(true); expect(journey.steps.every((s) => s.strategy === 'goto' && s.expected === 'redirect_login')).toBe(true); expect(journey.steps.every((s) => s.kind === 'page')).toBe(true); }); it('writes:false drops the flows but keeps the create page assertion', () => { const journey = buildRoleJourney(makePlan(), 'admin', { ...opts, writes: false }); expect(journey.steps.filter((s) => s.kind !== 'page')).toEqual([]); expect(journey.steps.some((s) => s.routeId === 'ADMINISTRATION_USERS_CREATE')).toBe(true); }); it('caps the journey and reports the truncation', () => { const journey = buildRoleJourney(makePlan(), 'admin', { ...opts, maxSteps: 2 }); expect(journey.steps).toHaveLength(2); expect(journey.truncated).toBe(5); }); it('an allowed detail under an unreachable parent degrades to goto with a note', () => { const plan = makePlan(); // Make the parent list denied for viewer while the detail stays allowed. plan.routes[0].access.viewer = 'denied'; const journey = buildRoleJourney(plan, 'viewer', opts); const detail = journey.steps.find((s) => s.routeId === 'ADMINISTRATION_USERS_DETAIL')!; expect(detail.strategy).toBe('goto'); expect(detail.note).toContain('parent list not reachable'); }); }); describe('buildJourneys', () => { it('builds per selected role in plan order, dropping unknown roles', () => { const journeys = buildJourneys(makePlan(), { writes: true, runTag: 't', maxSteps: 10, roles: ['viewer', 'ghost'] }); expect(journeys.map((j) => j.role)).toEqual(['viewer']); }); });