|
| 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's Action column and detail drawer show the declared label of |
| 11 | + * a row's action, the label the same page's Action filter already offers. |
| 12 | + * |
| 13 | + * Both used to print the stored value (`update`, `create`), so in a Chinese |
| 14 | + * console the column read in lowercase English beside a filter that read |
| 15 | + * 更新 / 创建. |
| 16 | + * |
| 17 | + * The transport is stubbed at the seams the page reads through: |
| 18 | + * - the console's metadata cache (`useMetadata().objects`) serves the |
| 19 | + * `sys_audit_log` document as a zh console receives it: its `action` |
| 20 | + * options carry translated labels, and `nightly_purge` is stored on a row |
| 21 | + * but declared by no option; |
| 22 | + * - `fetch` answers `/data/sys_audit_log` with the ledger below; |
| 23 | + * - the console adapter (`useAdapter`) serves `sys_user` to the actor lookup. |
| 24 | + * |
| 25 | + * Pinned: |
| 26 | + * - each Action cell shows its action's declared label, and an action no |
| 27 | + * option declares shows its stored value; |
| 28 | + * - the detail drawer's badge shows the same label, for a declared action |
| 29 | + * and for an undeclared one; |
| 30 | + * - with no `sys_audit_log` document served, the cells show stored values. |
| 31 | + */ |
| 32 | + |
| 33 | +import '@testing-library/jest-dom/vitest'; |
| 34 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 35 | +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 36 | +import { MemoryRouter } from 'react-router-dom'; |
| 37 | + |
| 38 | +const { ADAPTER, METADATA, SERVED_OBJECTS } = vi.hoisted(() => { |
| 39 | + const AUDIT = Object.freeze({ |
| 40 | + name: 'sys_audit_log', |
| 41 | + label: '审计日志', |
| 42 | + fields: { |
| 43 | + created_at: { type: 'datetime', label: '时间' }, |
| 44 | + action: { |
| 45 | + type: 'select', |
| 46 | + label: '操作', |
| 47 | + options: [ |
| 48 | + { value: 'create', label: '创建' }, |
| 49 | + { value: 'update', label: '更新' }, |
| 50 | + { value: 'delete', label: '删除' }, |
| 51 | + ], |
| 52 | + }, |
| 53 | + user_id: { type: 'lookup', reference: 'sys_user', label: '用户' }, |
| 54 | + }, |
| 55 | + }); |
| 56 | + const SERVED_OBJECTS = Object.freeze([{ name: 'crm_account', label: '客户' }, AUDIT]) as readonly unknown[]; |
| 57 | + return { |
| 58 | + ADAPTER: { |
| 59 | + find: async () => ({ data: [], total: 0 }), |
| 60 | + findOne: async () => null, |
| 61 | + getObjectSchema: async () => undefined, |
| 62 | + }, |
| 63 | + SERVED_OBJECTS, |
| 64 | + // Mutable per test: the served objects. |
| 65 | + METADATA: { objects: SERVED_OBJECTS }, |
| 66 | + }; |
| 67 | +}); |
| 68 | + |
| 69 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 70 | + ...(await importOriginal<Record<string, unknown>>()), |
| 71 | + useAdapter: () => ADAPTER, |
| 72 | + useMetadata: () => ({ objects: METADATA.objects }), |
| 73 | +})); |
| 74 | + |
| 75 | +// Imported after the mocks so the page picks them up. |
| 76 | +import { AuditLogPage } from './AuditLogPage'; |
| 77 | + |
| 78 | +/** The stored ledger, newest first: two declared actions and one no option declares. */ |
| 79 | +const LEDGER = [ |
| 80 | + { id: 'log_1', created_at: '2026-11-02T03:00:00.000Z', action: 'update', object_name: 'crm_account', record_id: 'acc_1', user_id: null }, |
| 81 | + { id: 'log_2', created_at: '2026-11-02T02:00:00.000Z', action: 'create', object_name: 'crm_account', record_id: 'acc_2', user_id: null }, |
| 82 | + { id: 'log_3', created_at: '2026-11-02T01:00:00.000Z', action: 'nightly_purge', object_name: 'crm_account', record_id: 'acc_3', user_id: null }, |
| 83 | +]; |
| 84 | + |
| 85 | +beforeEach(() => { |
| 86 | + METADATA.objects = SERVED_OBJECTS; |
| 87 | + vi.stubGlobal( |
| 88 | + 'fetch', |
| 89 | + vi.fn(async () => new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 })), |
| 90 | + ); |
| 91 | +}); |
| 92 | + |
| 93 | +afterEach(() => { |
| 94 | + cleanup(); |
| 95 | + vi.unstubAllGlobals(); |
| 96 | +}); |
| 97 | + |
| 98 | +function renderPage(): void { |
| 99 | + render(<MemoryRouter><AuditLogPage /></MemoryRouter>); |
| 100 | +} |
| 101 | + |
| 102 | +/** The body rows of the log table. */ |
| 103 | +function bodyRows(): HTMLElement[] { |
| 104 | + return screen.getAllByRole('row').slice(1); |
| 105 | +} |
| 106 | + |
| 107 | +/** The Action cell's badge text, by row. */ |
| 108 | +function actionCells(): string[] { |
| 109 | + return bodyRows().map((row) => row.querySelectorAll('td')[1]?.textContent ?? ''); |
| 110 | +} |
| 111 | + |
| 112 | +/** Open the detail drawer of the row at `index` and answer the drawer's title. */ |
| 113 | +async function openDrawer(index: number): Promise<HTMLElement> { |
| 114 | + await act(async () => { |
| 115 | + fireEvent.click(bodyRows()[index]!); |
| 116 | + }); |
| 117 | + const dialog = await screen.findByRole('dialog'); |
| 118 | + // The title's first child is the action badge; the object name follows it. |
| 119 | + const title = within(dialog).getByRole('heading'); |
| 120 | + return title.firstElementChild as HTMLElement; |
| 121 | +} |
| 122 | + |
| 123 | +describe('AuditLogPage — the Action column and drawer show the declared action label', () => { |
| 124 | + it('labels each Action cell with its declared option, and an undeclared action with its stored value', async () => { |
| 125 | + renderPage(); |
| 126 | + await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length)); |
| 127 | + |
| 128 | + expect(actionCells()).toEqual(['更新', '创建', 'nightly_purge']); |
| 129 | + }); |
| 130 | + |
| 131 | + it('labels the drawer badge as the row does', async () => { |
| 132 | + renderPage(); |
| 133 | + await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length)); |
| 134 | + |
| 135 | + expect(await openDrawer(0)).toHaveTextContent(/^更新$/); |
| 136 | + cleanup(); |
| 137 | + |
| 138 | + renderPage(); |
| 139 | + await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length)); |
| 140 | + expect(await openDrawer(2)).toHaveTextContent(/^nightly_purge$/); |
| 141 | + }); |
| 142 | + |
| 143 | + it('shows stored values when no sys_audit_log document is served', async () => { |
| 144 | + METADATA.objects = [{ name: 'crm_account', label: '客户' }]; |
| 145 | + renderPage(); |
| 146 | + await waitFor(() => expect(actionCells()).toHaveLength(LEDGER.length)); |
| 147 | + |
| 148 | + expect(actionCells()).toEqual(['update', 'create', 'nightly_purge']); |
| 149 | + }); |
| 150 | +}); |
0 commit comments