|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectui#11799 — the embedded item editor's one `GET …/layers` read. |
| 5 | + * |
| 6 | + * `EmbeddedItemEditor` edits an item that lives inside a parent body |
| 7 | + * (`object.fields.amount`). It reads the parent's layered view once, when the |
| 8 | + * author saves, to splice the item back into the parent's current body. It |
| 9 | + * holds only the item snapshot and the parent's name, so nothing in hand says |
| 10 | + * whether the parent was ever published. The framework answers `/layers` with |
| 11 | + * a 404 for a parent that exists only as a draft, and keeps that answer by |
| 12 | + * design (objectstack-ai/objectstack#22397, answer (a)). |
| 13 | + * |
| 14 | + * The REAL editor over a REAL `MetadataClient`, whose transport is an |
| 15 | + * in-memory server answering as the framework does. Requests are counted on |
| 16 | + * it. |
| 17 | + * |
| 18 | + * Pinned: |
| 19 | + * - opening an item of a draft-only parent sends no request at all, so it |
| 20 | + * cannot show an error: the read belongs to the save, not to the open; |
| 21 | + * - CONTROL: a published parent's save reads its layers once and splices the |
| 22 | + * item into the effective body it got; |
| 23 | + * - CONTROL: a failed `/layers` read (a 5xx) is the save's error, and nothing |
| 24 | + * is PUT. |
| 25 | + * |
| 26 | + * Not covered, said here so it does not read as covered: what a SAVE does for |
| 27 | + * a draft-only parent. `MetadataClient.layered()` resolves the 404 as an |
| 28 | + * envelope with every layer null, so the read itself raises nothing; the save |
| 29 | + * then splices the item into an empty body and PUTs the parent in publish |
| 30 | + * mode. That PUT is outside objectui#11799 (its claim excludes it), and it is |
| 31 | + * not pinned here, so this file does not hold that body in place. |
| 32 | + */ |
| 33 | + |
| 34 | +import '@testing-library/jest-dom/vitest'; |
| 35 | +import * as React from 'react'; |
| 36 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 37 | +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; |
| 38 | +import { MetadataClient } from '@object-ui/data-objectstack'; |
| 39 | + |
| 40 | +const FIELD_SCHEMA = { |
| 41 | + type: 'object', |
| 42 | + properties: { |
| 43 | + label: { type: 'string', title: 'Label' }, |
| 44 | + description: { type: 'string', title: 'Description' }, |
| 45 | + }, |
| 46 | +} satisfies Record<string, unknown>; |
| 47 | + |
| 48 | +const server = { |
| 49 | + active: new Map<string, Record<string, unknown>>(), |
| 50 | + drafts: new Map<string, Record<string, unknown>>(), |
| 51 | + requests: [] as Array<{ method: string; path: string; search: string; status: number }>, |
| 52 | + puts: [] as Array<{ search: string; body: Record<string, unknown> }>, |
| 53 | + /** Rows whose `/layers` read fails with a 5xx. */ |
| 54 | + failLayers: new Set<string>(), |
| 55 | +}; |
| 56 | + |
| 57 | +const json = (status: number, body: unknown) => |
| 58 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 59 | + |
| 60 | +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 61 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 62 | + const url = new URL(String(input), 'http://console.test'); |
| 63 | + const q = url.searchParams; |
| 64 | + let status = 200; |
| 65 | + let body: unknown = []; |
| 66 | + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); |
| 67 | + if (meta) { |
| 68 | + const seg = meta[1].split('/').map(decodeURIComponent); |
| 69 | + const key = `${seg[0]}/${seg[1]}`; |
| 70 | + if (seg[0] === '_drafts') { |
| 71 | + body = { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) }; |
| 72 | + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { |
| 73 | + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; |
| 74 | + } else if (seg.length === 3 && seg[2] === 'layers') { |
| 75 | + const row = server.active.get(key); |
| 76 | + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; |
| 77 | + else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; |
| 78 | + } else if (seg.length === 2 && method === 'PUT') { |
| 79 | + const item = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>; |
| 80 | + server.puts.push({ search: url.search, body: item }); |
| 81 | + if (q.get('mode') === 'draft') server.drafts.set(key, item); |
| 82 | + else server.active.set(key, item); |
| 83 | + body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' }; |
| 84 | + } else if (seg.length === 2) { |
| 85 | + const draftRead = q.get('state') === 'draft'; |
| 86 | + const row = (draftRead ? server.drafts : server.active).get(key); |
| 87 | + if (row) body = { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row }; |
| 88 | + else [status, body] = [404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }]; |
| 89 | + } |
| 90 | + } |
| 91 | + server.requests.push({ method, path: url.pathname, search: url.search, status }); |
| 92 | + return json(status, body); |
| 93 | +} |
| 94 | + |
| 95 | +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); |
| 96 | + |
| 97 | +vi.mock('./useMetadata', () => ({ |
| 98 | + useMetadataClient: () => client, |
| 99 | + useMetadataTypes: () => ({ |
| 100 | + loading: false, |
| 101 | + error: null, |
| 102 | + entries: [{ type: 'field', label: 'Field', allowOrgOverride: true, schema: FIELD_SCHEMA }], |
| 103 | + }), |
| 104 | +})); |
| 105 | + |
| 106 | +import { EmbeddedItemEditor } from './EmbeddedItemEditor'; |
| 107 | + |
| 108 | +const ORDER = { |
| 109 | + name: 'sales_order', |
| 110 | + label: 'Sales Order', |
| 111 | + fields: { amount: { type: 'number', label: 'Amount' }, region: { type: 'text', label: 'Region' } }, |
| 112 | +}; |
| 113 | + |
| 114 | +beforeEach(() => { |
| 115 | + server.active.clear(); |
| 116 | + server.drafts.clear(); |
| 117 | + server.requests = []; |
| 118 | + server.puts = []; |
| 119 | + server.failLayers.clear(); |
| 120 | +}); |
| 121 | +afterEach(cleanup); |
| 122 | + |
| 123 | +async function settle() { |
| 124 | + await act(async () => { |
| 125 | + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); |
| 126 | + }); |
| 127 | +} |
| 128 | + |
| 129 | +function openAmountField() { |
| 130 | + render( |
| 131 | + <EmbeddedItemEditor |
| 132 | + parentType="object" |
| 133 | + parentName="sales_order" |
| 134 | + embeddedPath="fields" |
| 135 | + itemName="amount" |
| 136 | + editAs="field" |
| 137 | + initialRaw={{ type: 'number', label: 'Amount' }} |
| 138 | + />, |
| 139 | + ); |
| 140 | +} |
| 141 | + |
| 142 | +const saveButton = () => screen.getByRole('button', { name: /save into object/i }); |
| 143 | +const layersStatuses = () => |
| 144 | + server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/object/sales_order/layers').map((r) => r.status); |
| 145 | + |
| 146 | +describe('EmbeddedItemEditor — the parent\'s /layers read belongs to the save (objectui#11799)', () => { |
| 147 | + it('opening an item of a draft-only parent sends no request, and shows no error', async () => { |
| 148 | + server.drafts.set('object/sales_order', ORDER); |
| 149 | + openAmountField(); |
| 150 | + await settle(); |
| 151 | + |
| 152 | + expect(screen.getByRole('textbox', { name: 'Label' })).toHaveValue('Amount'); |
| 153 | + expect(server.requests).toEqual([]); |
| 154 | + expect(screen.queryByText(/metadata store unavailable|Validation failed|absent/)).toBeNull(); |
| 155 | + }); |
| 156 | + |
| 157 | + it('CONTROL: a published parent\'s save reads its layers once and splices the item into the body it got', async () => { |
| 158 | + server.active.set('object/sales_order', ORDER); |
| 159 | + openAmountField(); |
| 160 | + fireEvent.change(screen.getByRole('textbox', { name: 'Label' }), { target: { value: 'Order amount' } }); |
| 161 | + fireEvent.click(saveButton()); |
| 162 | + |
| 163 | + expect(await screen.findByText('Saved.')).toBeInTheDocument(); |
| 164 | + expect(layersStatuses()).toEqual([200]); |
| 165 | + expect(server.puts).toHaveLength(1); |
| 166 | + expect(server.puts[0].body).toEqual({ |
| 167 | + ...ORDER, |
| 168 | + fields: { ...ORDER.fields, amount: { type: 'number', label: 'Order amount' } }, |
| 169 | + }); |
| 170 | + }); |
| 171 | + |
| 172 | + it('CONTROL: a failed /layers read (a 5xx) is the save\'s error, and nothing is PUT', async () => { |
| 173 | + server.active.set('object/sales_order', ORDER); |
| 174 | + server.failLayers.add('object/sales_order'); |
| 175 | + openAmountField(); |
| 176 | + fireEvent.click(saveButton()); |
| 177 | + |
| 178 | + expect(await screen.findByText('metadata store unavailable')).toBeInTheDocument(); |
| 179 | + await waitFor(() => expect(saveButton()).not.toBeDisabled()); |
| 180 | + expect(layersStatuses()).toEqual([500]); |
| 181 | + expect(server.puts).toEqual([]); |
| 182 | + expect(screen.queryByText('Saved.')).toBeNull(); |
| 183 | + }); |
| 184 | +}); |
0 commit comments