|
| 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 Object column, the detail drawer's title and its |
| 11 | + * target-record line name a row's object by its served label, the label the |
| 12 | + * same page's Object filter already offers. |
| 13 | + * |
| 14 | + * All three used to print the stored API name (`crm_account`, |
| 15 | + * `sys_flow_dispatch`), so in a Chinese console a row read 更新 beside |
| 16 | + * `crm_account`, and telling which business object changed meant knowing its |
| 17 | + * API name. |
| 18 | + * |
| 19 | + * The transport is stubbed at the seams the page reads through: |
| 20 | + * - the console's metadata cache (`useMetadata().objects`) serves the objects |
| 21 | + * as a zh console receives them: `crm_account` labelled 客户, and |
| 22 | + * `crm_blank` served with a blank label. `nightly_widget` is stored on a |
| 23 | + * row but served by no object; |
| 24 | + * - `fetch` answers `/data/sys_audit_log` with the ledger below; |
| 25 | + * - the console adapter (`useAdapter`) serves `sys_user` to the actor lookup. |
| 26 | + * |
| 27 | + * Pinned: |
| 28 | + * - each Object cell shows its object's served label, and an object the |
| 29 | + * metadata does not serve, or serves without a label, shows its stored |
| 30 | + * name; a row with no object shows the dash; |
| 31 | + * - the drawer's title and its target-record line show the same label, for a |
| 32 | + * served object and for an unserved one; |
| 33 | + * - the Object filter keeps its `Label (name)` choices. |
| 34 | + */ |
| 35 | + |
| 36 | +import '@testing-library/jest-dom/vitest'; |
| 37 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 38 | +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 39 | +import { MemoryRouter } from 'react-router-dom'; |
| 40 | + |
| 41 | +const { ADAPTER, METADATA, SERVED_OBJECTS } = vi.hoisted(() => { |
| 42 | + const SERVED_OBJECTS = Object.freeze([ |
| 43 | + { name: 'crm_account', label: '客户' }, |
| 44 | + { name: 'crm_blank', label: ' ' }, |
| 45 | + { name: 'sys_audit_log', label: '审计日志' }, |
| 46 | + ]) as readonly unknown[]; |
| 47 | + return { |
| 48 | + ADAPTER: { |
| 49 | + find: async () => ({ data: [], total: 0 }), |
| 50 | + findOne: async () => null, |
| 51 | + getObjectSchema: async () => undefined, |
| 52 | + }, |
| 53 | + SERVED_OBJECTS, |
| 54 | + // Mutable per test: the served objects. |
| 55 | + METADATA: { objects: SERVED_OBJECTS }, |
| 56 | + }; |
| 57 | +}); |
| 58 | + |
| 59 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 60 | + ...(await importOriginal<Record<string, unknown>>()), |
| 61 | + useAdapter: () => ADAPTER, |
| 62 | + useMetadata: () => ({ objects: METADATA.objects }), |
| 63 | +})); |
| 64 | + |
| 65 | +// Imported after the mocks so the page picks them up. |
| 66 | +import { AuditLogPage } from './AuditLogPage'; |
| 67 | + |
| 68 | +/** The stored ledger, newest first: a served object, an unserved one, a blank-labelled one, and none. */ |
| 69 | +const LEDGER = [ |
| 70 | + { id: 'log_1', created_at: '2026-11-02T03:00:00.000Z', action: 'update', object_name: 'crm_account', record_id: 'acc_1', user_id: null }, |
| 71 | + { id: 'log_2', created_at: '2026-11-02T02:00:00.000Z', action: 'create', object_name: 'nightly_widget', record_id: 'wid_1', user_id: null }, |
| 72 | + { id: 'log_3', created_at: '2026-11-02T01:00:00.000Z', action: 'update', object_name: 'crm_blank', record_id: 'blk_1', user_id: null }, |
| 73 | + { id: 'log_4', created_at: '2026-11-02T00:00:00.000Z', action: 'login', object_name: null, record_id: null, user_id: null }, |
| 74 | +]; |
| 75 | + |
| 76 | +beforeEach(() => { |
| 77 | + METADATA.objects = SERVED_OBJECTS; |
| 78 | + vi.stubGlobal( |
| 79 | + 'fetch', |
| 80 | + vi.fn(async () => new Response(JSON.stringify({ object: 'sys_audit_log', records: LEDGER, total: LEDGER.length }), { status: 200 })), |
| 81 | + ); |
| 82 | +}); |
| 83 | + |
| 84 | +afterEach(() => { |
| 85 | + cleanup(); |
| 86 | + vi.unstubAllGlobals(); |
| 87 | +}); |
| 88 | + |
| 89 | +function renderPage(): void { |
| 90 | + render(<MemoryRouter><AuditLogPage /></MemoryRouter>); |
| 91 | +} |
| 92 | + |
| 93 | +/** The body rows of the log table. */ |
| 94 | +function bodyRows(): HTMLElement[] { |
| 95 | + return screen.getAllByRole('row').slice(1); |
| 96 | +} |
| 97 | + |
| 98 | +/** The Object cell's text, by row. */ |
| 99 | +function objectCells(): string[] { |
| 100 | + return bodyRows().map((row) => row.querySelectorAll('td')[2]?.textContent ?? ''); |
| 101 | +} |
| 102 | + |
| 103 | +/** Open the detail drawer of the row at `index`. */ |
| 104 | +async function openDrawer(index: number): Promise<HTMLElement> { |
| 105 | + await act(async () => { |
| 106 | + fireEvent.click(bodyRows()[index]!); |
| 107 | + }); |
| 108 | + return screen.findByRole('dialog'); |
| 109 | +} |
| 110 | + |
| 111 | +/** The drawer title's object, the text after its action badge. */ |
| 112 | +function drawerTitleObject(dialog: HTMLElement): string { |
| 113 | + const title = within(dialog).getByRole('heading'); |
| 114 | + const badge = title.firstElementChild?.textContent ?? ''; |
| 115 | + return (title.textContent ?? '').slice(badge.length); |
| 116 | +} |
| 117 | + |
| 118 | +/** The drawer's target-record line, `object / record`. */ |
| 119 | +function drawerTarget(dialog: HTMLElement): string { |
| 120 | + const caption = within(dialog).getByText('Target record'); |
| 121 | + return caption.nextElementSibling?.textContent?.trim() ?? ''; |
| 122 | +} |
| 123 | + |
| 124 | +describe('AuditLogPage — the Object column and drawer show the served object label', () => { |
| 125 | + it('labels each Object cell with its served label, and an unserved or unlabelled object with its stored name', async () => { |
| 126 | + renderPage(); |
| 127 | + await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length)); |
| 128 | + |
| 129 | + expect(objectCells()).toEqual(['客户', 'nightly_widget', 'crm_blank', '—']); |
| 130 | + }); |
| 131 | + |
| 132 | + it("names a served object by its label in the drawer's title and target line", async () => { |
| 133 | + renderPage(); |
| 134 | + await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length)); |
| 135 | + |
| 136 | + const dialog = await openDrawer(0); |
| 137 | + expect(drawerTitleObject(dialog)).toBe('客户'); |
| 138 | + expect(drawerTarget(dialog)).toBe('客户 / acc_1'); |
| 139 | + }); |
| 140 | + |
| 141 | + it("names an unserved object by its stored name in the drawer's title and target line", async () => { |
| 142 | + renderPage(); |
| 143 | + await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length)); |
| 144 | + |
| 145 | + const dialog = await openDrawer(1); |
| 146 | + expect(drawerTitleObject(dialog)).toBe('nightly_widget'); |
| 147 | + expect(drawerTarget(dialog)).toBe('nightly_widget / wid_1'); |
| 148 | + }); |
| 149 | + |
| 150 | + it('shows stored names when the metadata serves no objects', async () => { |
| 151 | + METADATA.objects = []; |
| 152 | + renderPage(); |
| 153 | + await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length)); |
| 154 | + |
| 155 | + expect(objectCells()).toEqual(['crm_account', 'nightly_widget', 'crm_blank', '—']); |
| 156 | + }); |
| 157 | + |
| 158 | + it('keeps the Object filter on its Label (name) choices', async () => { |
| 159 | + renderPage(); |
| 160 | + await waitFor(() => expect(objectCells()).toHaveLength(LEDGER.length)); |
| 161 | + |
| 162 | + await act(async () => { |
| 163 | + fireEvent.click(screen.getByRole('combobox', { name: 'Object' })); |
| 164 | + }); |
| 165 | + const options = (await screen.findAllByRole('option')).map((o) => o.textContent); |
| 166 | + expect(options).toContain('客户 (crm_account)'); |
| 167 | + expect(options).toContain('crm_blank'); |
| 168 | + }); |
| 169 | +}); |
0 commit comments