|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#28576 (ADR-0146 D11) — Setup's item page over a PACKAGED item |
| 5 | + * shows the server's refusal of its package-less write as sent. |
| 6 | + * |
| 7 | + * The server closed the package-less write onto an item a code package ships: |
| 8 | + * a save or a reset naming no package is refused `403 NOT_OVERRIDABLE`, and |
| 9 | + * the sentence names the base's paired `.custom` package and the |
| 10 | + * `os migrate packageless-overlays` ceremony — the two things the |
| 11 | + * administrator can do. Setup's overlay page stays until its route retires |
| 12 | + * (Studio is the editor), so what it owes is to relay that sentence whole, in |
| 13 | + * the server's words: its error line leads with the first sentence and keeps |
| 14 | + * the full text behind "More" (objectui#13332). |
| 15 | + * |
| 16 | + * Pinned over the REAL page and a REAL `MetadataClient`, whose transport is an |
| 17 | + * in-memory server answering the `/meta` doors as the framework does after |
| 18 | + * the close: the packaged item's layers, and a `403` for each write that names |
| 19 | + * no package. Both the Save (a `PUT` with no `package`) and the Reset (a |
| 20 | + * `DELETE` with no `package`) are asserted to have reached the server with no |
| 21 | + * `package` key, so the refusal read back is the one that write was given. |
| 22 | + */ |
| 23 | + |
| 24 | +import '@testing-library/jest-dom/vitest'; |
| 25 | +import * as React from 'react'; |
| 26 | +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 27 | +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 28 | +import { MemoryRouter, Route, Routes } from 'react-router-dom'; |
| 29 | +import { z } from 'zod'; |
| 30 | +import { ComponentRegistry } from '@object-ui/core'; |
| 31 | +import { DocSchema } from '@objectstack/spec/system'; |
| 32 | +import { MetadataClient } from '@object-ui/data-objectstack'; |
| 33 | + |
| 34 | +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); |
| 35 | +vi.mock('sonner', () => ({ toast: toasts })); |
| 36 | + |
| 37 | +const NAME = 'shipped_guide'; |
| 38 | +const BASE = 'com.acme.handbook'; |
| 39 | +const PAIR = `${BASE}.custom`; |
| 40 | + |
| 41 | +const doc = (content: string) => ({ name: NAME, label: NAME, content, group: 'reference' }); |
| 42 | + |
| 43 | +/** The server's sentences, worded as the framework words them; read back verbatim below. */ |
| 44 | +const SAVE_CLOSED = |
| 45 | + `'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` + |
| 46 | + `of an installed package lives in its paired .custom package (ADR-0146 D11). Save it into '${PAIR}' ` + |
| 47 | + `(?package=${PAIR}), declaring overrides: { package: '${BASE}', basedOn }. A package-less customization stored ` + |
| 48 | + `before this release is still served; run \`os migrate packageless-overlays\` first to move it into '${PAIR}'.`; |
| 49 | +const RESET_CLOSED = |
| 50 | + `'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` + |
| 51 | + `of an installed package lives in its paired .custom package (ADR-0146 D11). Remove it from '${PAIR}' ` + |
| 52 | + `(?package=${PAIR}). A package-less customization stored before this release is still served; run ` + |
| 53 | + `\`os migrate packageless-overlays\` first to move it into '${PAIR}'.`; |
| 54 | + |
| 55 | +const server = { |
| 56 | + /** The packaged item's stored package-less row, as an unmigrated deployment holds it. */ |
| 57 | + overlay: doc('# Customized') as Record<string, unknown>, |
| 58 | + writes: [] as Array<{ method: string; query: URLSearchParams }>, |
| 59 | +}; |
| 60 | + |
| 61 | +const json = (status: number, body: unknown) => |
| 62 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 63 | + |
| 64 | +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 65 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 66 | + const url = new URL(String(input), 'http://console.test'); |
| 67 | + const q = url.searchParams; |
| 68 | + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); |
| 69 | + if (!meta) return json(200, []); |
| 70 | + const seg = meta[1].split('/').map(decodeURIComponent); |
| 71 | + if (seg[0] === '_drafts') return json(200, { drafts: [] }); |
| 72 | + if (seg.length === 1) return json(200, { items: [] }); |
| 73 | + if (seg.length === 3 && seg[2] === 'layers') { |
| 74 | + const code = { ...doc('# Shipped'), _packageId: BASE, _provenance: 'package' }; |
| 75 | + return json(200, { |
| 76 | + code, overlay: server.overlay, overlayScope: 'env', effective: server.overlay, |
| 77 | + provenance: 'package', packageId: BASE, editable: true, deletable: true, resettable: true, lock: 'none', |
| 78 | + }); |
| 79 | + } |
| 80 | + if (seg.length === 3 && seg[2] === 'references') return json(200, []); |
| 81 | + if (seg.length === 2 && (method === 'PUT' || method === 'DELETE')) { |
| 82 | + server.writes.push({ method, query: new URLSearchParams(q) }); |
| 83 | + // After the close: a write naming no package onto this packaged item is refused. |
| 84 | + if (!q.get('package')) { |
| 85 | + return json(403, { error: method === 'PUT' ? SAVE_CLOSED : RESET_CLOSED, code: 'NOT_OVERRIDABLE' }); |
| 86 | + } |
| 87 | + return json(200, { success: true }); |
| 88 | + } |
| 89 | + if (seg.length === 2) { |
| 90 | + if (q.get('state') === 'draft') return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); |
| 91 | + return json(200, { type: seg[0], name: seg[1], item: server.overlay }); |
| 92 | + } |
| 93 | + return json(200, {}); |
| 94 | +} |
| 95 | + |
| 96 | +const client = vi.hoisted(() => ({ current: null as unknown })); |
| 97 | + |
| 98 | +vi.mock('./useMetadata', async (importOriginal) => { |
| 99 | + const mod = await importOriginal<typeof import('./useMetadata')>(); |
| 100 | + return { |
| 101 | + ...mod, |
| 102 | + useMetadataClient: () => client.current, |
| 103 | + useMetadataTypes: () => ({ |
| 104 | + loading: false, |
| 105 | + entries: [ |
| 106 | + // An overlay-able type as the registry serves the five: the page opens |
| 107 | + // the packaged item for editing on this flag (objectui#12141). |
| 108 | + { type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA }, |
| 109 | + ], |
| 110 | + }), |
| 111 | + }; |
| 112 | +}); |
| 113 | + |
| 114 | +import { MetadataResourceEditPage } from './ResourceEditPage'; |
| 115 | +import { registerDefaultMetadataSchemas } from './default-schemas'; |
| 116 | +import { registerBuiltinPreviews } from './previews'; |
| 117 | + |
| 118 | +const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record<string, unknown>; |
| 119 | +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); |
| 120 | + |
| 121 | +registerDefaultMetadataSchemas(); |
| 122 | +registerBuiltinPreviews(); |
| 123 | + |
| 124 | +const STUB_NS = 'packageless-write-closed-28576'; |
| 125 | +function StubMarkdown({ schema }: { schema: { content?: string } }) { |
| 126 | + return <div data-testid="markdown-render">{schema.content}</div>; |
| 127 | +} |
| 128 | +beforeAll(() => { |
| 129 | + ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS }); |
| 130 | +}); |
| 131 | +afterAll(() => { |
| 132 | + ComponentRegistry.unregister('markdown', STUB_NS); |
| 133 | +}); |
| 134 | + |
| 135 | +beforeEach(() => { |
| 136 | + server.overlay = doc('# Customized'); |
| 137 | + server.writes = []; |
| 138 | + for (const fn of Object.values(toasts)) fn.mockClear(); |
| 139 | + window.localStorage.setItem('metadata-admin:autosave', '0'); |
| 140 | +}); |
| 141 | +afterEach(() => { |
| 142 | + cleanup(); |
| 143 | + window.localStorage.removeItem('metadata-admin:autosave'); |
| 144 | + window.history.replaceState(null, '', '/'); |
| 145 | +}); |
| 146 | + |
| 147 | +async function settle() { |
| 148 | + await act(async () => { |
| 149 | + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); |
| 150 | + }); |
| 151 | +} |
| 152 | + |
| 153 | +async function open() { |
| 154 | + const path = `/metadata/doc/${NAME}`; |
| 155 | + window.history.replaceState(null, '', path); |
| 156 | + render( |
| 157 | + <MemoryRouter initialEntries={[path]}> |
| 158 | + <Routes> |
| 159 | + <Route path="/metadata/doc/:name" element={<MetadataResourceEditPage type="doc" name={NAME} />} /> |
| 160 | + <Route path="/metadata/doc" element={<span data-testid="list-view">list</span>} /> |
| 161 | + </Routes> |
| 162 | + </MemoryRouter>, |
| 163 | + ); |
| 164 | + const source = await screen.findByRole('textbox', { name: 'Markdown source' }, { timeout: 8000 }); |
| 165 | + await settle(); |
| 166 | + return source; |
| 167 | +} |
| 168 | + |
| 169 | +/** The page's error line, opened whole: the server's sentence as sent. */ |
| 170 | +async function refusalShown(): Promise<string> { |
| 171 | + fireEvent.click(await screen.findByTestId('refusal-more')); |
| 172 | + return screen.getByTestId('refusal-detail').textContent ?? ''; |
| 173 | +} |
| 174 | + |
| 175 | +describe('Setup shows the server\'s refusal of a package-less write onto a packaged item as sent (objectstack#28576)', () => { |
| 176 | + it('Save: the PUT names no package, and the refusal is the page\'s error line, word for word', async () => { |
| 177 | + const source = await open(); |
| 178 | + fireEvent.change(source, { target: { value: '# Customized again' } }); |
| 179 | + fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ })); |
| 180 | + |
| 181 | + await waitFor(() => expect(server.writes.filter((w) => w.method === 'PUT')).toHaveLength(1)); |
| 182 | + await settle(); |
| 183 | + expect(server.writes[0]!.query.has('package')).toBe(false); |
| 184 | + expect(await refusalShown()).toBe(SAVE_CLOSED); |
| 185 | + expect(toasts.success).not.toHaveBeenCalled(); |
| 186 | + }); |
| 187 | + |
| 188 | + it('Reset: the DELETE names no package, and the refusal is the page\'s error line, word for word; the page stays', async () => { |
| 189 | + await open(); |
| 190 | + fireEvent.click(screen.getByTestId('reset-or-delete-button')); |
| 191 | + const dialog = await screen.findByRole('alertdialog'); |
| 192 | + fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action')); |
| 193 | + |
| 194 | + await waitFor(() => expect(server.writes.filter((w) => w.method === 'DELETE')).toHaveLength(1)); |
| 195 | + await settle(); |
| 196 | + expect([...server.writes[0]!.query.keys()]).toEqual([]); |
| 197 | + expect(await refusalShown()).toBe(RESET_CLOSED); |
| 198 | + expect(screen.queryByTestId('list-view')).toBeNull(); |
| 199 | + }); |
| 200 | +}); |
0 commit comments