|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26649 — the metadata edit page opened with `?member=` reads and |
| 5 | + * writes THAT bundle member. |
| 6 | + * |
| 7 | + * Setup's list links each language of an `email_template` to |
| 8 | + * `…/email_template/NAME?member=LOCALE`. The page forwards the member to its |
| 9 | + * layered read and its draft read (ruling 6444406836), and to every by-name |
| 10 | + * write it issues for the item — publish, reset and discard — because each of |
| 11 | + * those doors addresses the CANONICAL member when it is named none: Reset on |
| 12 | + * the `zh-CN` page would otherwise remove the `en-US` overlay. |
| 13 | + * |
| 14 | + * The REAL page over a REAL `MetadataClient`, whose transport is an in-memory |
| 15 | + * server keyed by `(name, member)` as the framework keys a bundle (canonical |
| 16 | + * member `en-US`), so a request that drops the member lands on the other |
| 17 | + * language and the assertion on what is stored goes red. |
| 18 | + */ |
| 19 | + |
| 20 | +import '@testing-library/jest-dom/vitest'; |
| 21 | +import * as React from 'react'; |
| 22 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 23 | +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 24 | +import { MemoryRouter, Route, Routes } from 'react-router-dom'; |
| 25 | +import { MetadataClient } from '@object-ui/data-objectstack'; |
| 26 | + |
| 27 | +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); |
| 28 | + |
| 29 | +const CANONICAL = 'en-US'; |
| 30 | +const NAME = 'duly.task_overdue'; |
| 31 | + |
| 32 | +const tpl = (locale: string, subject: string) => ({ |
| 33 | + name: NAME, |
| 34 | + label: `Overdue ${locale}`, |
| 35 | + locale, |
| 36 | + subject, |
| 37 | + bodyHtml: `<p>${subject}</p>`, |
| 38 | +}); |
| 39 | + |
| 40 | +/** One member's stored state, as the framework holds a bundle row. */ |
| 41 | +interface MemberRows { |
| 42 | + code: Record<string, unknown> | null; |
| 43 | + overlay: Record<string, unknown> | null; |
| 44 | + draft: Record<string, unknown> | null; |
| 45 | +} |
| 46 | + |
| 47 | +const server = { |
| 48 | + members: new Map<string, MemberRows>(), |
| 49 | + requests: [] as Array<{ method: string; path: string; query: URLSearchParams }>, |
| 50 | +}; |
| 51 | + |
| 52 | +const json = (status: number, body: unknown) => |
| 53 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 54 | + |
| 55 | +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 56 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 57 | + const url = new URL(String(input), 'http://console.test'); |
| 58 | + const q = url.searchParams; |
| 59 | + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); |
| 60 | + if (!meta) return json(200, []); |
| 61 | + const seg = meta[1].split('/').map(decodeURIComponent); |
| 62 | + server.requests.push({ method, path: seg.join('/'), query: q }); |
| 63 | + if (seg[0] === '_drafts') { |
| 64 | + return json(200, { |
| 65 | + drafts: [...server.members.entries()] |
| 66 | + .filter(([, rows]) => rows.draft) |
| 67 | + .map(([member]) => ({ type: 'email_template', name: NAME, member, packageId: null })), |
| 68 | + }); |
| 69 | + } |
| 70 | + if (seg.length === 1) return json(200, { items: [] }); |
| 71 | + const member = q.get('member') ?? CANONICAL; |
| 72 | + const rows = server.members.get(member); |
| 73 | + if (seg.length === 3 && seg[2] === 'layers') { |
| 74 | + if (!rows || (!rows.code && !rows.overlay)) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } }); |
| 75 | + const effective = rows.overlay ?? rows.code; |
| 76 | + return json(200, { |
| 77 | + code: rows.code, overlay: rows.overlay, overlayScope: rows.overlay ? 'env' : null, effective, |
| 78 | + editable: true, deletable: true, resettable: !!(rows.code && rows.overlay), lock: 'none', |
| 79 | + }); |
| 80 | + } |
| 81 | + if (seg.length === 3 && seg[2] === 'references') return json(200, []); |
| 82 | + if (seg.length === 3 && seg[2] === 'publish' && method === 'POST') { |
| 83 | + if (!rows?.draft) return json(404, { error: { code: 'NO_DRAFT', message: 'no draft' } }); |
| 84 | + rows.overlay = rows.draft; |
| 85 | + rows.draft = null; |
| 86 | + return json(200, { success: true, version: 'sha256:1', seq: 2, message: 'published' }); |
| 87 | + } |
| 88 | + if (seg.length === 2 && method === 'DELETE') { |
| 89 | + if (rows) { |
| 90 | + if (q.get('state') === 'draft') rows.draft = null; |
| 91 | + else rows.overlay = null; |
| 92 | + } |
| 93 | + return json(200, {}); |
| 94 | + } |
| 95 | + if (seg.length === 2 && method === 'GET') { |
| 96 | + const draftRead = q.get('state') === 'draft'; |
| 97 | + const row = draftRead ? rows?.draft : (rows?.overlay ?? rows?.code); |
| 98 | + return row |
| 99 | + ? json(200, { type: 'email_template', name: NAME, item: draftRead ? { ...row, _draft: true } : row, ...(draftRead ? { version: 'sha256:d' } : {}) }) |
| 100 | + : json(404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }); |
| 101 | + } |
| 102 | + return json(200, {}); |
| 103 | +} |
| 104 | + |
| 105 | +const client = vi.hoisted(() => ({ current: null as unknown })); |
| 106 | + |
| 107 | +vi.mock('./useMetadata', async (importOriginal) => { |
| 108 | + const mod = await importOriginal<typeof import('./useMetadata')>(); |
| 109 | + return { |
| 110 | + ...mod, |
| 111 | + useMetadataClient: () => client.current, |
| 112 | + useMetadataTypes: () => ({ |
| 113 | + loading: false, |
| 114 | + entries: [ |
| 115 | + { type: 'email_template', label: 'Email Template', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true }, |
| 116 | + ], |
| 117 | + }), |
| 118 | + }; |
| 119 | +}); |
| 120 | + |
| 121 | +import { MetadataResourceEditPage } from './ResourceEditPage'; |
| 122 | +import { registerDefaultMetadataSchemas } from './default-schemas'; |
| 123 | + |
| 124 | +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); |
| 125 | +registerDefaultMetadataSchemas(); |
| 126 | + |
| 127 | +beforeEach(() => { |
| 128 | + server.members.clear(); |
| 129 | + server.requests.length = 0; |
| 130 | + server.members.set(CANONICAL, { code: tpl(CANONICAL, 'Overdue'), overlay: null, draft: null }); |
| 131 | + server.members.set('zh-CN', { |
| 132 | + code: tpl('zh-CN', '逾期'), |
| 133 | + overlay: tpl('zh-CN', '逾期了'), |
| 134 | + draft: tpl('zh-CN', '逾期了,请处理'), |
| 135 | + }); |
| 136 | + window.localStorage.setItem('metadata-admin:autosave', '0'); |
| 137 | + Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: vi.fn(() => true) }); |
| 138 | +}); |
| 139 | +afterEach(() => { |
| 140 | + cleanup(); |
| 141 | + window.localStorage.removeItem('metadata-admin:autosave'); |
| 142 | +}); |
| 143 | + |
| 144 | +async function settle() { |
| 145 | + await act(async () => { |
| 146 | + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); |
| 147 | + }); |
| 148 | +} |
| 149 | + |
| 150 | +function renderEdit(search: string) { |
| 151 | + render( |
| 152 | + <MemoryRouter initialEntries={[`/metadata/email_template/${NAME}${search}`]}> |
| 153 | + <Routes> |
| 154 | + <Route path="/metadata/email_template/:name" element={<MetadataResourceEditPage type="email_template" name={NAME} />} /> |
| 155 | + </Routes> |
| 156 | + </MemoryRouter>, |
| 157 | + ); |
| 158 | +} |
| 159 | + |
| 160 | +/** The requests to one door: `layers`, `publish`, the draft read, or a DELETE. */ |
| 161 | +const sent = (pick: (r: (typeof server.requests)[number]) => boolean) => server.requests.filter(pick); |
| 162 | +const layerReads = () => sent((r) => r.path.endsWith('/layers')); |
| 163 | +const draftReads = () => sent((r) => r.method === 'GET' && r.query.get('state') === 'draft'); |
| 164 | + |
| 165 | +async function confirmDialog() { |
| 166 | + const dialog = await screen.findByRole('alertdialog'); |
| 167 | + fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action')); |
| 168 | +} |
| 169 | + |
| 170 | +describe('MetadataResourceEditPage — ?member= opens that bundle member (objectstack#26649)', () => { |
| 171 | + it('reads the member\'s layers and draft, and shows its own body', async () => { |
| 172 | + renderEdit('?scope=environment&member=zh-CN'); |
| 173 | + expect(await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 })).toBeInTheDocument(); |
| 174 | + await settle(); |
| 175 | + |
| 176 | + expect(layerReads().length).toBeGreaterThan(0); |
| 177 | + for (const r of layerReads()) expect(r.query.get('member')).toBe('zh-CN'); |
| 178 | + expect(draftReads().length).toBeGreaterThan(0); |
| 179 | + for (const r of draftReads()) expect(r.query.get('member')).toBe('zh-CN'); |
| 180 | + expect(screen.queryByDisplayValue('Overdue')).not.toBeInTheDocument(); |
| 181 | + }); |
| 182 | + |
| 183 | + it('CONTROL: with no ?member= the page reads the canonical member and sends no selector', async () => { |
| 184 | + renderEdit('?scope=environment'); |
| 185 | + expect(await screen.findByDisplayValue('Overdue', undefined, { timeout: 8000 })).toBeInTheDocument(); |
| 186 | + await settle(); |
| 187 | + |
| 188 | + for (const r of server.requests) expect(r.query.has('member')).toBe(false); |
| 189 | + }); |
| 190 | + |
| 191 | + it('Publish promotes the opened member\'s draft, not the canonical member\'s', async () => { |
| 192 | + renderEdit('?scope=environment&member=zh-CN'); |
| 193 | + await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 }); |
| 194 | + await settle(); |
| 195 | + |
| 196 | + fireEvent.click(screen.getByTitle('Publish')); |
| 197 | + await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull()); |
| 198 | + await settle(); |
| 199 | + |
| 200 | + const publishes = sent((r) => r.path.endsWith('/publish')); |
| 201 | + expect(publishes).toHaveLength(1); |
| 202 | + expect(publishes[0]!.query.get('member')).toBe('zh-CN'); |
| 203 | + expect(server.members.get('zh-CN')!.overlay).toMatchObject({ subject: '逾期了,请处理' }); |
| 204 | + }); |
| 205 | + |
| 206 | + it('Discard draft discards the opened member\'s draft', async () => { |
| 207 | + renderEdit('?scope=environment&member=zh-CN'); |
| 208 | + await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 }); |
| 209 | + await settle(); |
| 210 | + |
| 211 | + fireEvent.click(screen.getAllByTitle('Discard draft')[0]!); |
| 212 | + await confirmDialog(); |
| 213 | + await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull()); |
| 214 | + |
| 215 | + const deletes = sent((r) => r.method === 'DELETE'); |
| 216 | + expect(deletes).toHaveLength(1); |
| 217 | + expect(deletes[0]!.query.get('state')).toBe('draft'); |
| 218 | + expect(deletes[0]!.query.get('member')).toBe('zh-CN'); |
| 219 | + }); |
| 220 | + |
| 221 | + it('Reset removes the opened member\'s overlay and leaves the other language alone', async () => { |
| 222 | + server.members.get('zh-CN')!.draft = null; |
| 223 | + server.members.get(CANONICAL)!.overlay = tpl(CANONICAL, 'Overdue, customised'); |
| 224 | + renderEdit('?scope=environment&member=zh-CN'); |
| 225 | + await screen.findByDisplayValue('逾期了', undefined, { timeout: 8000 }); |
| 226 | + await settle(); |
| 227 | + |
| 228 | + fireEvent.click(screen.getByTestId('reset-or-delete-button')); |
| 229 | + await confirmDialog(); |
| 230 | + await waitFor(() => expect(server.members.get('zh-CN')!.overlay).toBeNull()); |
| 231 | + await settle(); |
| 232 | + |
| 233 | + const deletes = sent((r) => r.method === 'DELETE'); |
| 234 | + expect(deletes).toHaveLength(1); |
| 235 | + expect(deletes[0]!.query.get('member')).toBe('zh-CN'); |
| 236 | + expect(server.members.get(CANONICAL)!.overlay).toMatchObject({ subject: 'Overdue, customised' }); |
| 237 | + // The re-read after the reset is the same member's layers. |
| 238 | + expect(layerReads().at(-1)!.query.get('member')).toBe('zh-CN'); |
| 239 | + expect(await screen.findByDisplayValue('逾期')).toBeInTheDocument(); |
| 240 | + }); |
| 241 | +}); |
0 commit comments