|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * The Audit Log drawer opens the row's own record page. |
| 11 | + * |
| 12 | + * A settings write records a `config_change` row that carries only a digest. |
| 13 | + * The CRUD mirror writes its own rows about the same settings row, and those |
| 14 | + * hold the value. Both name the settings row as `record_id`, and the |
| 15 | + * platform's record page for `sys_audit_log` (`sys_audit_log_detail`, in |
| 16 | + * `plugin-audit`) lists every ledger row with the row's `record_id`. The |
| 17 | + * drawer had no way to reach that page: "Open as list" opens the object list, |
| 18 | + * not a row. |
| 19 | + * |
| 20 | + * The router is real: the page mounts at `/apps/:appName/system/audit-log`, as |
| 21 | + * `systemRoutes` mounts it, and the console's record route |
| 22 | + * `/apps/:appName/:objectName/record/:recordId` holds a probe that reads back |
| 23 | + * what the link handed it. |
| 24 | + * |
| 25 | + * Pinned: |
| 26 | + * - on a `config_change` row the drawer links to that row's record page under |
| 27 | + * the list's app, says that page lists the record's other changes, and the |
| 28 | + * link lands there carrying the way back to this page; |
| 29 | + * - the landed row names the settings row the mirror's update row names, so |
| 30 | + * the page's same-record list holds the update row; |
| 31 | + * - a row about no record still links to its own page, without the hint; |
| 32 | + * - under zh the link and the hint read the zh pack. |
| 33 | + * |
| 34 | + * Not pinned here, and pinned where the code lives: the page's same-record |
| 35 | + * list (`plugin-audit`'s `sys-audit-log-page.test.ts`, "the same-record list |
| 36 | + * is the ledger, keyed on `record_id` on both sides") and the related list's |
| 37 | + * `relationshipValueField` read (`plugin-detail`). |
| 38 | + */ |
| 39 | + |
| 40 | +import '@testing-library/jest-dom/vitest'; |
| 41 | +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; |
| 42 | +import { render, screen, cleanup, act, fireEvent, within } from '@testing-library/react'; |
| 43 | +import { MemoryRouter, Route, Routes, useLocation, useParams } from 'react-router-dom'; |
| 44 | +import { I18nProvider } from '@object-ui/i18n'; |
| 45 | +import { builtInLocales } from '@object-ui/i18n/locales'; |
| 46 | + |
| 47 | +const { ADAPTER } = vi.hoisted(() => ({ |
| 48 | + ADAPTER: { |
| 49 | + find: async () => ({ data: [], total: 0 }), |
| 50 | + findOne: async () => null, |
| 51 | + getObjectSchema: async () => undefined, |
| 52 | + }, |
| 53 | +})); |
| 54 | + |
| 55 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 56 | + ...(await importOriginal<Record<string, unknown>>()), |
| 57 | + useAdapter: () => ADAPTER, |
| 58 | +})); |
| 59 | + |
| 60 | +import { AuditLogPage } from './AuditLogPage'; |
| 61 | + |
| 62 | +/** The settings row both writers name. */ |
| 63 | +const SETTINGS_ROW = 'set_mail_from_0001'; |
| 64 | + |
| 65 | +/** The `config_change` row: a digest, no value. */ |
| 66 | +const CONFIG_CHANGE = { |
| 67 | + id: 'log_cfg_0001', |
| 68 | + created_at: '2026-11-06T10:00:01.000Z', |
| 69 | + action: 'config_change', |
| 70 | + object_name: 'sys_setting', |
| 71 | + record_id: SETTINGS_ROW, |
| 72 | + new_value: '{"digest":"sha256:9f2c"}', |
| 73 | +}; |
| 74 | + |
| 75 | +/** The CRUD mirror's update row about the same settings row: it holds the value. */ |
| 76 | +const MIRROR_UPDATE = { |
| 77 | + id: 'log_upd_0002', |
| 78 | + created_at: '2026-11-06T10:00:00.000Z', |
| 79 | + action: 'update', |
| 80 | + object_name: 'sys_setting', |
| 81 | + record_id: SETTINGS_ROW, |
| 82 | + old_value: '{"value":"noreply@old.example"}', |
| 83 | + new_value: '{"value":"noreply@new.example"}', |
| 84 | +}; |
| 85 | + |
| 86 | +/** A row about no record (an import run). */ |
| 87 | +const IMPORT_RUN = { |
| 88 | + id: 'log_imp_0003', |
| 89 | + created_at: '2026-11-06T09:00:00.000Z', |
| 90 | + action: 'import', |
| 91 | + object_name: 'account', |
| 92 | + record_id: null, |
| 93 | +}; |
| 94 | + |
| 95 | +const LEDGER = [CONFIG_CHANGE, MIRROR_UPDATE, IMPORT_RUN]; |
| 96 | + |
| 97 | +beforeEach(() => { |
| 98 | + vi.stubGlobal( |
| 99 | + 'fetch', |
| 100 | + vi.fn(async () => |
| 101 | + new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 }), |
| 102 | + ), |
| 103 | + ); |
| 104 | +}); |
| 105 | + |
| 106 | +afterEach(() => { |
| 107 | + cleanup(); |
| 108 | + vi.unstubAllGlobals(); |
| 109 | +}); |
| 110 | + |
| 111 | +async function settle(): Promise<void> { |
| 112 | + for (let i = 0; i < 6; i++) { |
| 113 | + await act(async () => { |
| 114 | + await new Promise((r) => setTimeout(r, 0)); |
| 115 | + }); |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +/** The console's record route, answering with what the link handed it. */ |
| 120 | +function RecordRouteProbe() { |
| 121 | + const { appName, objectName, recordId } = useParams(); |
| 122 | + const from = (useLocation().state as { from?: { pathname?: string; label?: string } } | null)?.from; |
| 123 | + return ( |
| 124 | + <div data-testid="record-route"> |
| 125 | + <span data-testid="app">{appName}</span> |
| 126 | + <span data-testid="object">{objectName}</span> |
| 127 | + <span data-testid="record">{recordId}</span> |
| 128 | + <span data-testid="from-path">{from?.pathname}</span> |
| 129 | + <span data-testid="from-label">{from?.label}</span> |
| 130 | + </div> |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +function renderAt(path = '/apps/setup/system/audit-log') { |
| 135 | + return render( |
| 136 | + <MemoryRouter initialEntries={[path]}> |
| 137 | + <Routes> |
| 138 | + <Route path="/apps/:appName/system/audit-log" element={<AuditLogPage />} /> |
| 139 | + <Route path="/apps/:appName/:objectName/record/:recordId" element={<RecordRouteProbe />} /> |
| 140 | + </Routes> |
| 141 | + </MemoryRouter>, |
| 142 | + ); |
| 143 | +} |
| 144 | + |
| 145 | +/** Opens the drawer on one ledger row. The table lists the rows in served order under its header row. */ |
| 146 | +async function openDrawerOn(rowId: string): Promise<HTMLElement> { |
| 147 | + await screen.findAllByRole('cell'); |
| 148 | + await settle(); |
| 149 | + const index = LEDGER.findIndex((r) => r.id === rowId); |
| 150 | + const rows = screen.getAllByRole('row'); |
| 151 | + expect(rows).toHaveLength(LEDGER.length + 1); |
| 152 | + fireEvent.click(rows[index + 1]); |
| 153 | + const drawer = await screen.findByRole('dialog'); |
| 154 | + // The drawer is the chosen row's: it shows that row's id. |
| 155 | + expect(within(drawer).getByText(rowId)).toBeInTheDocument(); |
| 156 | + return drawer; |
| 157 | +} |
| 158 | + |
| 159 | +describe('AuditLogPage — the drawer opens the row\'s record page', () => { |
| 160 | + it('on a config_change row, links to that row\'s page and says it lists the record\'s other changes', async () => { |
| 161 | + renderAt(); |
| 162 | + const drawer = await openDrawerOn(CONFIG_CHANGE.id); |
| 163 | + const link = within(drawer).getByRole('link', { name: /Open entry page/ }); |
| 164 | + expect(link).toHaveAttribute('href', `/apps/setup/sys_audit_log/record/${CONFIG_CHANGE.id}`); |
| 165 | + expect(within(drawer).getByText(builtInLocales.en.console.system.auditLog.openEntryPageHint)).toBeInTheDocument(); |
| 166 | + }); |
| 167 | + |
| 168 | + it('the link lands on the record route with the row id and the way back to this page', async () => { |
| 169 | + renderAt(); |
| 170 | + const drawer = await openDrawerOn(CONFIG_CHANGE.id); |
| 171 | + fireEvent.click(within(drawer).getByRole('link', { name: /Open entry page/ })); |
| 172 | + expect(await screen.findByTestId('record-route')).toBeInTheDocument(); |
| 173 | + expect(screen.getByTestId('app')).toHaveTextContent('setup'); |
| 174 | + expect(screen.getByTestId('object')).toHaveTextContent('sys_audit_log'); |
| 175 | + expect(screen.getByTestId('record')).toHaveTextContent(CONFIG_CHANGE.id); |
| 176 | + expect(screen.getByTestId('from-path')).toHaveTextContent('/apps/setup/system/audit-log'); |
| 177 | + expect(screen.getByTestId('from-label')).toHaveTextContent('Audit Log'); |
| 178 | + // The landed row's `record_id` is the one the mirror's update row names, |
| 179 | + // so the page's same-record list (keyed on `record_id` on both sides) |
| 180 | + // holds the update row and its value. |
| 181 | + const landed = LEDGER.find((r) => r.id === screen.getByTestId('record').textContent); |
| 182 | + const sameRecord = LEDGER.filter((r) => r.record_id === landed?.record_id).map((r) => r.id); |
| 183 | + expect(sameRecord).toContain(MIRROR_UPDATE.id); |
| 184 | + }); |
| 185 | + |
| 186 | + it('keeps the app segment the page is mounted under', async () => { |
| 187 | + renderAt('/apps/crm/system/audit-log'); |
| 188 | + const drawer = await openDrawerOn(CONFIG_CHANGE.id); |
| 189 | + expect(within(drawer).getByRole('link', { name: /Open entry page/ })) |
| 190 | + .toHaveAttribute('href', `/apps/crm/sys_audit_log/record/${CONFIG_CHANGE.id}`); |
| 191 | + }); |
| 192 | + |
| 193 | + it('a row about no record still links to its own page, without the hint', async () => { |
| 194 | + renderAt(); |
| 195 | + const drawer = await openDrawerOn(IMPORT_RUN.id); |
| 196 | + expect(within(drawer).getByRole('link', { name: /Open entry page/ })) |
| 197 | + .toHaveAttribute('href', `/apps/setup/sys_audit_log/record/${IMPORT_RUN.id}`); |
| 198 | + expect(within(drawer).queryByText(builtInLocales.en.console.system.auditLog.openEntryPageHint)).toBeNull(); |
| 199 | + }); |
| 200 | + |
| 201 | + it('under zh, the link and the hint read the zh pack', async () => { |
| 202 | + const zhAudit = builtInLocales.zh.console.system.auditLog; |
| 203 | + // Guard: real zh rows, not English copied over. |
| 204 | + expect(zhAudit.openEntryPage).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPage); |
| 205 | + expect(zhAudit.openEntryPageHint).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPageHint); |
| 206 | + render( |
| 207 | + <I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}> |
| 208 | + <MemoryRouter initialEntries={['/apps/setup/system/audit-log']}> |
| 209 | + <Routes> |
| 210 | + <Route path="/apps/:appName/system/audit-log" element={<AuditLogPage />} /> |
| 211 | + </Routes> |
| 212 | + </MemoryRouter> |
| 213 | + </I18nProvider>, |
| 214 | + ); |
| 215 | + const drawer = await openDrawerOn(CONFIG_CHANGE.id); |
| 216 | + expect(within(drawer).getByRole('link', { name: new RegExp(zhAudit.openEntryPage) })).toBeInTheDocument(); |
| 217 | + expect(within(drawer).getByText(zhAudit.openEntryPageHint)).toBeInTheDocument(); |
| 218 | + }); |
| 219 | +}); |
0 commit comments