|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `sys_audit_log`'s record page: the full page this plugin serves through its |
| 5 | + * manifest `pages` (`sys-audit-log.page.ts`), whose job is the list of every |
| 6 | + * ledger row about the same record beside the row being read. |
| 7 | + * |
| 8 | + * Four questions, each answered against the shipped artifacts: |
| 9 | + * |
| 10 | + * 1. the plugin's manifest carries the page; |
| 11 | + * 2. the page parses as a page, and every node's props parse against its |
| 12 | + * component's own props row (the per-type door `os validate` judges); |
| 13 | + * 3. the body draws every field the object declares (a `full` page draws |
| 14 | + * only what it authors), and the same-record list is the ledger keyed on |
| 15 | + * `record_id` on both sides, with no "New" on an append-only object; |
| 16 | + * 4. its label is translated in every shipped locale, `en` byte-equal to |
| 17 | + * the page literal. |
| 18 | + * |
| 19 | + * What the list SELECTS on a real engine and driver, the mirror's rows about |
| 20 | + * a settings write included, is `sys-audit-log-same-record.integration.test.ts`. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect } from 'vitest'; |
| 24 | +import { PageSchema, ComponentPropsMap } from '@objectstack/spec/ui'; |
| 25 | +import { AuditPlugin } from './audit-plugin.js'; |
| 26 | +import { SysAuditLogDetailPage } from './sys-audit-log.page.js'; |
| 27 | +import { SysAuditLog } from './objects/index.js'; |
| 28 | +import { AuditTranslations } from './translations/index.js'; |
| 29 | +import { auditPageTranslations } from './translations/pages.js'; |
| 30 | + |
| 31 | +type AnyRec = Record<string, any>; |
| 32 | + |
| 33 | +const PAGE = SysAuditLogDetailPage as AnyRec; |
| 34 | +const COMPONENTS = (PAGE.regions?.[0]?.components ?? []) as AnyRec[]; |
| 35 | +const nodeOf = (type: string): AnyRec => COMPONENTS.find((c) => c.type === type) ?? {}; |
| 36 | +const FIELDS = SysAuditLog.fields as AnyRec; |
| 37 | + |
| 38 | +/** The plugin's one manifest, as `init` registers it. */ |
| 39 | +async function registeredManifest(): Promise<AnyRec> { |
| 40 | + const registered: AnyRec[] = []; |
| 41 | + const ctx: any = { |
| 42 | + getService: (name: string) => { |
| 43 | + if (name === 'manifest') return { register: (m: AnyRec) => registered.push(m) }; |
| 44 | + throw new Error(`no service '${name}'`); |
| 45 | + }, |
| 46 | + registerService: () => {}, |
| 47 | + hook: () => {}, |
| 48 | + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, |
| 49 | + }; |
| 50 | + await new AuditPlugin().init(ctx); |
| 51 | + expect(registered).toHaveLength(1); |
| 52 | + return registered[0]; |
| 53 | +} |
| 54 | + |
| 55 | +describe('sys_audit_log record page — served through the manifest', () => { |
| 56 | + it('the plugin manifest carries the page in `pages`', async () => { |
| 57 | + expect((await registeredManifest()).pages).toEqual([SysAuditLogDetailPage]); |
| 58 | + }); |
| 59 | + |
| 60 | + it('is the default full record page of sys_audit_log, and parses as a page', () => { |
| 61 | + expect(PAGE).toMatchObject({ |
| 62 | + name: 'sys_audit_log_detail', |
| 63 | + type: 'record', |
| 64 | + object: 'sys_audit_log', |
| 65 | + kind: 'full', |
| 66 | + isDefault: true, |
| 67 | + }); |
| 68 | + const parsed = PageSchema.safeParse(PAGE); |
| 69 | + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); |
| 70 | + }); |
| 71 | + |
| 72 | + it('every node\'s props parse against its own component props row', () => { |
| 73 | + const map = ComponentPropsMap as Record<string, { safeParse: (v: unknown) => { success: boolean; error?: any } }>; |
| 74 | + const verdicts = COMPONENTS.map((c) => { |
| 75 | + const row = map[c.type]; |
| 76 | + const result = row?.safeParse(c.properties ?? {}); |
| 77 | + return { type: c.type, known: !!row, ok: !!result?.success, issues: result?.error?.issues ?? [] }; |
| 78 | + }); |
| 79 | + expect(verdicts.filter((v) => !v.known || !v.ok)).toEqual([]); |
| 80 | + // Control: the door refuses what it should, so the green above is a verdict. |
| 81 | + expect(map['record:related_list'].safeParse({ objectName: 'sys_audit_log', relationshipField: 'record_id', relatedField: 'x' }).success) |
| 82 | + .toBe(false); |
| 83 | + }); |
| 84 | +}); |
| 85 | + |
| 86 | +describe('sys_audit_log record page — the body', () => { |
| 87 | + it('authors the header, the strip, the details, the same-record list and the discussion, in that order', () => { |
| 88 | + expect(COMPONENTS.map((c) => c.type)).toEqual([ |
| 89 | + 'page:header', 'record:highlights', 'record:details', 'record:related_list', 'record:discussion', |
| 90 | + ]); |
| 91 | + expect(nodeOf('page:header')).toEqual({ type: 'page:header', properties: { placedActions: true } }); |
| 92 | + }); |
| 93 | + |
| 94 | + it('the strip is the object\'s highlightFields minus the title field, and holds neither half of the pointer pair', () => { |
| 95 | + const strip = nodeOf('record:highlights').properties.fields as string[]; |
| 96 | + expect(strip).toEqual((SysAuditLog.highlightFields ?? []).filter((f: string) => f !== SysAuditLog.nameField)); |
| 97 | + expect(strip).not.toContain('record_id'); |
| 98 | + expect(strip).not.toContain('object_name'); |
| 99 | + }); |
| 100 | + |
| 101 | + it('the details draw every field the object declares, the system id apart, in its declared groups', () => { |
| 102 | + const sections = nodeOf('record:details').properties.sections as AnyRec[]; |
| 103 | + const drawn = sections.flatMap((s) => s.fields as string[]); |
| 104 | + const declared = Object.keys(FIELDS).filter((f) => f !== 'id'); |
| 105 | + expect([...drawn].sort()).toEqual([...declared].sort()); |
| 106 | + // Each section holds one declared group's fields, so the body reads as the object groups them. |
| 107 | + for (const section of sections) { |
| 108 | + const groups = new Set((section.fields as string[]).map((f) => FIELDS[f]?.group)); |
| 109 | + expect(groups.size, String(section.label?.en)).toBe(1); |
| 110 | + expect([...groups][0]).toBe(section.label.en); |
| 111 | + } |
| 112 | + }); |
| 113 | + |
| 114 | + it('the same-record list is the ledger, keyed on `record_id` on both sides, newest first, with no "New"', () => { |
| 115 | + const list = nodeOf('record:related_list').properties; |
| 116 | + expect(list).toMatchObject({ |
| 117 | + objectName: 'sys_audit_log', |
| 118 | + relationshipField: 'record_id', |
| 119 | + relationshipValueField: 'record_id', |
| 120 | + sort: [{ field: 'created_at', order: 'desc' }], |
| 121 | + // An authored empty action list withholds "New"; the object is append-only. |
| 122 | + actions: [], |
| 123 | + }); |
| 124 | + expect((SysAuditLog as AnyRec).enable?.apiMethods).toEqual(['get', 'list']); |
| 125 | + // The value it keys on is the pointer's id half, a declared column of this object. |
| 126 | + expect(FIELDS.record_id?.referenceVia).toBe('object_name'); |
| 127 | + // Every column it draws is a field of the object, the two snapshots included. |
| 128 | + const columns = list.columns as string[]; |
| 129 | + expect(columns.filter((c) => !(c in FIELDS))).toEqual([]); |
| 130 | + expect(columns).toEqual(expect.arrayContaining(['old_value', 'new_value'])); |
| 131 | + }); |
| 132 | + |
| 133 | + it('every inline label carries the eleven locales this plugin ships', () => { |
| 134 | + const locales = Object.keys(auditPageTranslations).sort(); |
| 135 | + const labels: AnyRec[] = [ |
| 136 | + ...(nodeOf('record:details').properties.sections as AnyRec[]).map((s) => s.label), |
| 137 | + nodeOf('record:related_list').properties.title, |
| 138 | + ]; |
| 139 | + for (const label of labels) expect(Object.keys(label).sort(), String(label.en)).toEqual(locales); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +describe('sys_audit_log record page — its label is translated', () => { |
| 144 | + it('`en` mirrors the page literal byte for byte', () => { |
| 145 | + expect(auditPageTranslations.en.sys_audit_log_detail?.label).toBe(PAGE.label); |
| 146 | + }); |
| 147 | + |
| 148 | + it('every shipped locale serves its own label, the object\'s label in that locale', () => { |
| 149 | + for (const [locale, bundle] of Object.entries(AuditTranslations)) { |
| 150 | + const served = (bundle as AnyRec).pages?.sys_audit_log_detail?.label; |
| 151 | + expect(served, locale).toBe((auditPageTranslations as AnyRec)[locale].sys_audit_log_detail.label); |
| 152 | + expect(served, locale).toBe((bundle as AnyRec).objects?.sys_audit_log?.label); |
| 153 | + } |
| 154 | + }); |
| 155 | +}); |
0 commit comments