|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#29015 — a pending dashboard draft the server refuses to read |
| 5 | + * (`403 PERMISSION_DENIED`: its `requiredPermissions` name a capability the |
| 6 | + * author does not hold) is never taken for "no draft". The Interfaces pillar |
| 7 | + * installs no buffer over it: the published layer is not shown to edit, the |
| 8 | + * strip says why, and the autosave sends nothing, so the draft keeps its keys |
| 9 | + * and its audience gate. |
| 10 | + * |
| 11 | + * The client is a server double: `layered` serves the published row, |
| 12 | + * `getDraft` serves the stored draft row raw, or refuses it as the read gate |
| 13 | + * does (an `Error` carrying `status` and `code`, the shape the metadata |
| 14 | + * client throws), and `save` records the wire body and its precondition. The |
| 15 | + * dashboard canvas is a stub that prints the draft it received and offers one |
| 16 | + * edit, so nothing in the pillar itself is stubbed. |
| 17 | + */ |
| 18 | + |
| 19 | +import '@testing-library/jest-dom/vitest'; |
| 20 | +import * as React from 'react'; |
| 21 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 22 | +import { render, screen, cleanup, waitFor, act } from '@testing-library/react'; |
| 23 | +import userEvent from '@testing-library/user-event'; |
| 24 | +import { MemoryRouter } from 'react-router-dom'; |
| 25 | +import { t } from '../metadata-admin/i18n'; |
| 26 | + |
| 27 | +const PKG = 'com.acme.app'; |
| 28 | +const NAV = [{ id: 'nav_sales', type: 'dashboard', label: 'Sales', dashboardName: 'sales_board' }]; |
| 29 | +const APP = { name: 'acme_app', label: 'Acme', navigation: NAV }; |
| 30 | +const PUBLISHED_DASHBOARD = { name: 'sales_board', label: 'Sales', widgets: [] }; |
| 31 | +/** The pending draft: the published body plus an audience gate the author does not hold. */ |
| 32 | +const GATED_DRAFT = { ...PUBLISHED_DASHBOARD, requiredPermissions: ['showcase.export_data'] }; |
| 33 | +const GATE_REFUSAL = "You do not have permission to open the 'sales_board' dashboard."; |
| 34 | + |
| 35 | +const server = vi.hoisted(() => ({ |
| 36 | + active: new Map<string, Record<string, unknown>>(), |
| 37 | + drafts: new Map<string, Record<string, unknown>>(), |
| 38 | + /** Draft rows whose read is refused as the audience gate refuses it. */ |
| 39 | + gated: new Set<string>(), |
| 40 | + saves: [] as Array<{ type: string; name: string; body: Record<string, unknown>; opts: Record<string, unknown> | undefined }>, |
| 41 | +})); |
| 42 | + |
| 43 | +const mockClient = vi.hoisted(() => { |
| 44 | + const k = (type: string, name: string) => `${type}/${name}`; |
| 45 | + const toWire = (doc: unknown) => JSON.parse(JSON.stringify(doc)) as Record<string, unknown>; |
| 46 | + return { |
| 47 | + list: vi.fn(async (type: string) => |
| 48 | + [...server.active.entries()] |
| 49 | + .filter(([key]) => key.startsWith(`${type}/`)) |
| 50 | + .map(([, row]) => ({ name: row.name, label: row.label ?? row.name })), |
| 51 | + ), |
| 52 | + listDrafts: vi.fn(async () => []), |
| 53 | + listTypes: vi.fn(async () => ({ entries: [] })), |
| 54 | + get: vi.fn(async () => null), |
| 55 | + references: vi.fn(async () => []), |
| 56 | + layered: vi.fn(async (type: string, name: string) => { |
| 57 | + const eff = server.active.get(k(type, name)) ?? null; |
| 58 | + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; |
| 59 | + }), |
| 60 | + getDraft: vi.fn(async (type: string, name: string) => { |
| 61 | + if (server.gated.has(k(type, name))) { |
| 62 | + throw Object.assign(new Error(GATE_REFUSAL), { |
| 63 | + status: 403, |
| 64 | + code: 'PERMISSION_DENIED', |
| 65 | + body: { success: false, error: { code: 'PERMISSION_DENIED', message: GATE_REFUSAL } }, |
| 66 | + }); |
| 67 | + } |
| 68 | + const row = server.drafts.get(k(type, name)); |
| 69 | + // The client resolves a 404 `NO_DRAFT` read as `null`. |
| 70 | + if (!row) return null; |
| 71 | + return { type, name, version: 'v1', item: { ...row } }; |
| 72 | + }), |
| 73 | + save: vi.fn(async (type: string, name: string, item: unknown, opts?: Record<string, unknown>) => { |
| 74 | + const body = toWire(item); |
| 75 | + server.saves.push({ type, name, body, opts }); |
| 76 | + if (opts?.mode === 'draft') server.drafts.set(k(type, name), body); |
| 77 | + else server.active.set(k(type, name), body); |
| 78 | + return { success: true, version: 'v2', state: 'draft' }; |
| 79 | + }), |
| 80 | + publish: vi.fn(async () => ({ success: true })), |
| 81 | + reset: vi.fn(async () => ({})), |
| 82 | + }; |
| 83 | +}); |
| 84 | + |
| 85 | +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { |
| 86 | + const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>(); |
| 87 | + return { |
| 88 | + ...mod, |
| 89 | + useMetadataClient: () => mockClient, |
| 90 | + useMetadataTypes: () => ({ entries: [] }), |
| 91 | + }; |
| 92 | +}); |
| 93 | + |
| 94 | +vi.mock('./packages-io', async (importOriginal) => { |
| 95 | + const mod = await importOriginal<typeof import('./packages-io')>(); |
| 96 | + return { ...mod, fetchPackages: vi.fn(async () => []) }; |
| 97 | +}); |
| 98 | + |
| 99 | +vi.mock('@object-ui/react', async (importOriginal) => { |
| 100 | + const mod = await importOriginal<typeof import('@object-ui/react')>(); |
| 101 | + return { ...mod, useAdapter: () => dataSource }; |
| 102 | +}); |
| 103 | + |
| 104 | +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); |
| 105 | + |
| 106 | +import { InterfacesPillar } from './StudioDesignSurface'; |
| 107 | +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; |
| 108 | +import { registerMetadataPreview, getMetadataPreview } from '../metadata-admin/preview-registry'; |
| 109 | + |
| 110 | +const dataSource = createEmptyDataSource(); |
| 111 | +failOnAbsorbedFetchError(); |
| 112 | + |
| 113 | +/** Canvas stand-in: prints the draft it was handed and offers one edit through `onPatch`. */ |
| 114 | +function StubCanvas({ draft, onPatch }: { draft: Record<string, unknown>; onPatch?: (patch: Record<string, unknown>) => void }) { |
| 115 | + return ( |
| 116 | + <div> |
| 117 | + <pre data-testid="surface-draft">{JSON.stringify(draft)}</pre> |
| 118 | + {onPatch && ( |
| 119 | + <button type="button" data-testid="stub-edit" onClick={() => onPatch({ label: 'Sales (edited)' })}> |
| 120 | + edit |
| 121 | + </button> |
| 122 | + )} |
| 123 | + </div> |
| 124 | + ); |
| 125 | +} |
| 126 | + |
| 127 | +const realPreview = getMetadataPreview('dashboard'); |
| 128 | +const key = (type: string, name: string) => `${type}/${name}`; |
| 129 | +const wire = (doc: unknown) => JSON.parse(JSON.stringify(doc)) as Record<string, unknown>; |
| 130 | + |
| 131 | +function renderPillar() { |
| 132 | + render( |
| 133 | + <MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}> |
| 134 | + <InterfacesPillar packageId={PKG} /> |
| 135 | + </MemoryRouter>, |
| 136 | + ); |
| 137 | +} |
| 138 | + |
| 139 | +/** Longer than the autosave's debounce, so a save that was going to leave has left. */ |
| 140 | +async function pastTheAutosave() { |
| 141 | + await act(async () => { |
| 142 | + await new Promise((resolve) => setTimeout(resolve, 2500)); |
| 143 | + }); |
| 144 | +} |
| 145 | + |
| 146 | +beforeEach(() => { |
| 147 | + server.active.clear(); |
| 148 | + server.drafts.clear(); |
| 149 | + server.gated.clear(); |
| 150 | + server.saves.length = 0; |
| 151 | + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); |
| 152 | + registerMetadataPreview('dashboard', StubCanvas as never); |
| 153 | + server.active.set(key('app', 'acme_app'), wire(APP)); |
| 154 | + server.active.set(key('dashboard', 'sales_board'), wire(PUBLISHED_DASHBOARD)); |
| 155 | +}); |
| 156 | + |
| 157 | +afterEach(() => { |
| 158 | + cleanup(); |
| 159 | + if (realPreview) registerMetadataPreview('dashboard', realPreview); |
| 160 | +}); |
| 161 | + |
| 162 | +describe('StudioDesignSurface — a dashboard draft the server refuses to read is not "no draft" (objectstack#29015)', () => { |
| 163 | + it('opens no buffer over the refused draft: the strip says why, nothing is editable, and no save is sent', async () => { |
| 164 | + server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT)); |
| 165 | + server.gated.add(key('dashboard', 'sales_board')); |
| 166 | + |
| 167 | + renderPillar(); |
| 168 | + |
| 169 | + // The leaf's draft was asked for, at the address its saves write. |
| 170 | + await waitFor( |
| 171 | + () => expect(mockClient.getDraft).toHaveBeenCalledWith('dashboard', 'sales_board', { packageId: PKG }), |
| 172 | + { timeout: 8000 }, |
| 173 | + ); |
| 174 | + // Red on base: the refusal was read as "no draft", and the published body |
| 175 | + // was installed as the buffer and handed to the canvas to edit. |
| 176 | + expect( |
| 177 | + await screen.findByText(t('engine.studio.refusal.draftUnreadable', 'en-US'), undefined, { timeout: 8000 }), |
| 178 | + ).toBeInTheDocument(); |
| 179 | + // The server's own sentence stays reachable, behind Details. |
| 180 | + expect(screen.getByTestId('studio-refusal')).toHaveTextContent(GATE_REFUSAL); |
| 181 | + expect(screen.queryByTestId('surface-draft')).toBeNull(); |
| 182 | + expect(screen.queryByTestId('stub-edit')).toBeNull(); |
| 183 | + // The published layer is never read to stand in for the draft. |
| 184 | + expect(mockClient.layered).not.toHaveBeenCalledWith('dashboard', 'sales_board', expect.anything()); |
| 185 | + |
| 186 | + await pastTheAutosave(); |
| 187 | + expect(server.saves).toEqual([]); |
| 188 | + expect(server.drafts.get(key('dashboard', 'sales_board'))).toStrictEqual(wire(GATED_DRAFT)); |
| 189 | + }); |
| 190 | + |
| 191 | + it('CONTROL: with no pending draft the published body opens, and an edit saves pinned to "no draft row"', async () => { |
| 192 | + renderPillar(); |
| 193 | + |
| 194 | + await waitFor( |
| 195 | + () => |
| 196 | + expect(JSON.parse(screen.getByTestId('surface-draft').textContent ?? '{}')).toStrictEqual( |
| 197 | + wire(PUBLISHED_DASHBOARD), |
| 198 | + ), |
| 199 | + { timeout: 8000 }, |
| 200 | + ); |
| 201 | + expect(screen.queryByText(t('engine.studio.refusal.draftUnreadable', 'en-US'))).toBeNull(); |
| 202 | + |
| 203 | + await userEvent.click(screen.getByTestId('stub-edit')); |
| 204 | + await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); |
| 205 | + const { type, name, body, opts } = server.saves[0]!; |
| 206 | + expect([type, name]).toEqual(['dashboard', 'sales_board']); |
| 207 | + expect(body.label).toBe('Sales (edited)'); |
| 208 | + expect(opts).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*' }); |
| 209 | + expect(opts).not.toHaveProperty('ifMatch'); |
| 210 | + }); |
| 211 | +}); |
0 commit comments