|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#30327 and the ui half of objectstack#30331 — the New datasource |
| 5 | + * dialog. |
| 6 | + * |
| 7 | + * #30327: the dialog started on the managed schema mode, which has no Sync and |
| 8 | + * whose schema sync adds an object's columns to an existing table of the same |
| 9 | + * name, on a page whose purpose is to read an existing database. Nothing said |
| 10 | + * what either mode does, and a managed row just had no Sync button. |
| 11 | + * #30331: a text box's default (SQLite's `:memory:`) was the box's VALUE, read |
| 12 | + * as a hint, so a typed path was appended to it (`:memory:/tmp/parts.db`). |
| 13 | + * |
| 14 | + * What is pinned: |
| 15 | + * - (a) a new datasource starts on External, the select says what External |
| 16 | + * does and when to pick it, and Create sends `schemaMode: 'external'` with |
| 17 | + * the `external: {}` block the schema requires; |
| 18 | + * - (b) choosing Managed swaps the sentence for the managed one, and Create |
| 19 | + * sends `managed` with no block; |
| 20 | + * - (c) a text or number box with a default starts empty with the default as |
| 21 | + * its placeholder; a typed path is the whole value, and Test and Create |
| 22 | + * send exactly it; |
| 23 | + * - (d) CONTROL: a box left empty sends its default, so the request is what |
| 24 | + * it was; a switch keeps its default as its value; |
| 25 | + * - (e) an edit shows the stored values, no placeholder, and sends them as |
| 26 | + * they stand; |
| 27 | + * - (f) a managed row says why it has no Sync; an external row and the |
| 28 | + * primary datasource say nothing; |
| 29 | + * - (g) zh: the sentences read in Chinese. |
| 30 | + */ |
| 31 | + |
| 32 | +import * as React from 'react'; |
| 33 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 34 | +import { render, screen, cleanup, fireEvent, within, waitFor } from '@testing-library/react'; |
| 35 | +import userEvent from '@testing-library/user-event'; |
| 36 | +import { I18nProvider } from '@object-ui/i18n'; |
| 37 | +import { toast } from 'sonner'; |
| 38 | +import { t } from '../i18n.js'; |
| 39 | + |
| 40 | +type Reply = { ok: boolean; status: number; text: () => Promise<string> }; |
| 41 | + |
| 42 | +const state = vi.hoisted(() => ({ |
| 43 | + fetch: (async () => undefined) as unknown as (url: string, init?: RequestInit) => Promise<unknown>, |
| 44 | + // STABLE identity, like the real memoized client. |
| 45 | + metadataClient: {}, |
| 46 | +})); |
| 47 | + |
| 48 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 49 | + ...(await importOriginal<Record<string, unknown>>()), |
| 50 | + createAuthenticatedFetch: () => (url: string, init?: RequestInit) => state.fetch(url, init), |
| 51 | +})); |
| 52 | +vi.mock('../useMetadata.js', () => ({ |
| 53 | + useMetadataClient: () => state.metadataClient, |
| 54 | +})); |
| 55 | +vi.mock('sonner', () => ({ |
| 56 | + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), message: vi.fn() }, |
| 57 | +})); |
| 58 | + |
| 59 | +import { DatasourceResourcePage } from './DatasourceResourcePage'; |
| 60 | + |
| 61 | +// The zh rows (g) reads live in the `studio` namespace's lazily fetched `zh` |
| 62 | +// catalogue; resident before any case, so they read synchronously. |
| 63 | +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; |
| 64 | +await loadStudioCatalogue('zh'); |
| 65 | + |
| 66 | +afterEach(() => { |
| 67 | + cleanup(); |
| 68 | + vi.mocked(toast.success).mockClear(); |
| 69 | + vi.mocked(toast.error).mockClear(); |
| 70 | +}); |
| 71 | + |
| 72 | +const reply = (status: number, payload: unknown): Reply => ({ |
| 73 | + ok: status < 400, |
| 74 | + status, |
| 75 | + text: async () => JSON.stringify(payload), |
| 76 | +}); |
| 77 | +const ok = (data: unknown, status = 200) => reply(status, { success: true, data }); |
| 78 | + |
| 79 | +/** The catalog's SQLite entry as `GET /datasources/drivers` serves it: a defaulted text box and a defaulted switch. */ |
| 80 | +const SQLITE = { |
| 81 | + id: 'sqlite', |
| 82 | + label: 'SQLite', |
| 83 | + configSchema: { |
| 84 | + properties: { |
| 85 | + filename: { type: 'string', title: 'Filename', default: ':memory:', description: 'Database file path, or ":memory:" for an ephemeral database' }, |
| 86 | + wal: { type: 'boolean', title: 'WAL', default: true }, |
| 87 | + }, |
| 88 | + }, |
| 89 | +}; |
| 90 | +/** A defaulted number box beside a defaulted text box. */ |
| 91 | +const POSTGRES = { |
| 92 | + id: 'postgres', |
| 93 | + label: 'PostgreSQL', |
| 94 | + configSchema: { |
| 95 | + properties: { |
| 96 | + host: { type: 'string', title: 'Host', default: 'localhost' }, |
| 97 | + port: { type: 'integer', title: 'Port', default: 5432 }, |
| 98 | + database: { type: 'string', title: 'Database' }, |
| 99 | + }, |
| 100 | + }, |
| 101 | +}; |
| 102 | + |
| 103 | +interface Seen { method: string; url: string; body?: Record<string, unknown> } |
| 104 | + |
| 105 | +function renderPage(opts: { rows?: unknown[]; stored?: Record<string, unknown>; language?: string } = {}) { |
| 106 | + const seen: Seen[] = []; |
| 107 | + state.fetch = (async (url: string, init?: RequestInit) => { |
| 108 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 109 | + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as Record<string, unknown>) : undefined; |
| 110 | + seen.push({ method, url, body }); |
| 111 | + if (url === '/api/v1/datasources/drivers') return ok({ drivers: [SQLITE, POSTGRES] }); |
| 112 | + if (url === '/api/v1/datasources' && method === 'GET') return ok({ datasources: opts.rows ?? [] }); |
| 113 | + if (url === '/api/v1/datasources' && method === 'POST') return ok({ datasource: { name: body?.name } }, 201); |
| 114 | + if (url === '/api/v1/datasources/test') return ok({ result: { ok: true } }); |
| 115 | + if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'GET') { |
| 116 | + return ok({ datasource: opts.stored }); |
| 117 | + } |
| 118 | + if (opts.stored && url === `/api/v1/datasources/${String(opts.stored.name)}` && method === 'PATCH') { |
| 119 | + return ok({ datasource: { name: opts.stored.name } }); |
| 120 | + } |
| 121 | + return reply(404, { success: false, error: { code: 'NOT_FOUND', message: `unexpected ${method} ${url}` } }); |
| 122 | + }) as typeof state.fetch; |
| 123 | + render( |
| 124 | + <I18nProvider config={{ defaultLanguage: opts.language ?? 'en', detectBrowserLanguage: false }}> |
| 125 | + <DatasourceResourcePage /> |
| 126 | + </I18nProvider>, |
| 127 | + ); |
| 128 | + return seen; |
| 129 | +} |
| 130 | + |
| 131 | +/** The input or select trigger beside a form label (the page's labels carry no `htmlFor`). */ |
| 132 | +function controlUnder(label: string | RegExp): HTMLElement { |
| 133 | + const lab = screen.getByText(label, { selector: 'label' }); |
| 134 | + // A switch sits beside the div that holds its label; every other control under it. |
| 135 | + const control = lab.parentElement?.querySelector('input, [role="combobox"], [role="switch"]') |
| 136 | + ?? lab.parentElement?.parentElement?.querySelector('[role="switch"]'); |
| 137 | + expect(control, `no control beside the "${String(label)}" label`).toBeTruthy(); |
| 138 | + return control as HTMLElement; |
| 139 | +} |
| 140 | + |
| 141 | +async function openNew(language = 'en') { |
| 142 | + fireEvent.click(await screen.findByRole('button', { name: t('engine.datasource.new', language === 'zh' ? 'zh-CN' : 'en-US') })); |
| 143 | + return screen.findByRole('dialog'); |
| 144 | +} |
| 145 | + |
| 146 | +async function chooseDriver(label: string) { |
| 147 | + await userEvent.click(controlUnder(/^Driver/)); |
| 148 | + await userEvent.click(await screen.findByRole('option', { name: label })); |
| 149 | +} |
| 150 | + |
| 151 | +async function chooseMode(name: RegExp) { |
| 152 | + await userEvent.click(controlUnder('Schema mode')); |
| 153 | + await userEvent.click(await screen.findByRole('option', { name })); |
| 154 | +} |
| 155 | + |
| 156 | +const lastWrite = (seen: Seen[], method: string, url: string) => { |
| 157 | + const hits = seen.filter((r) => r.method === method && r.url === url); |
| 158 | + expect(hits.length, `a ${method} ${url} was sent`).toBeGreaterThan(0); |
| 159 | + return hits[hits.length - 1].body ?? {}; |
| 160 | +}; |
| 161 | + |
| 162 | +const EN = 'en-US'; |
| 163 | +const ZH = 'zh-CN'; |
| 164 | + |
| 165 | +describe('New datasource — starts on External and says what each schema mode does (objectstack#30327)', () => { |
| 166 | + it('(a) starts on External with its sentence, and Create sends external with the block', async () => { |
| 167 | + const seen = renderPage(); |
| 168 | + const dialog = await openNew(); |
| 169 | + expect(within(controlUnder('Schema mode')).getByText(t('engine.datasource.schemaModeExternal', EN))).toBeTruthy(); |
| 170 | + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', EN)); |
| 171 | + fireEvent.change(controlUnder('Name *'), { target: { value: 'parts_db' } }); |
| 172 | + fireEvent.change(controlUnder('Filename'), { target: { value: '/srv/data/parts.db' } }); |
| 173 | + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); |
| 174 | + await waitFor(() => expect(toast.success).toHaveBeenCalled()); |
| 175 | + const post = lastWrite(seen, 'POST', '/api/v1/datasources'); |
| 176 | + expect(post).toMatchObject({ name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' } }); |
| 177 | + expect(post.external).toEqual({}); |
| 178 | + }); |
| 179 | + |
| 180 | + it('(b) Managed swaps the sentence, and Create sends managed with no block', async () => { |
| 181 | + const seen = renderPage(); |
| 182 | + const dialog = await openNew(); |
| 183 | + await chooseMode(/^Managed/); |
| 184 | + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeManagedHint', EN)); |
| 185 | + await chooseMode(/^Validate only/); |
| 186 | + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeValidateOnlyHint', EN)); |
| 187 | + await chooseMode(/^Managed/); |
| 188 | + fireEvent.change(controlUnder('Name *'), { target: { value: 'scratch' } }); |
| 189 | + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); |
| 190 | + await waitFor(() => expect(toast.success).toHaveBeenCalled()); |
| 191 | + const post = lastWrite(seen, 'POST', '/api/v1/datasources'); |
| 192 | + expect(post.schemaMode).toBe('managed'); |
| 193 | + expect('external' in post).toBe(false); |
| 194 | + }); |
| 195 | +}); |
| 196 | + |
| 197 | +describe('New datasource — a box default is a placeholder, not a value (objectstack#30331)', () => { |
| 198 | + it('(c) Filename starts empty with :memory: as its placeholder; a typed path is the whole value', async () => { |
| 199 | + const seen = renderPage(); |
| 200 | + const dialog = await openNew(); |
| 201 | + const filename = controlUnder('Filename') as HTMLInputElement; |
| 202 | + expect(filename.value).toBe(''); |
| 203 | + expect(filename.placeholder).toBe(':memory:'); |
| 204 | + await userEvent.type(filename, '/srv/data/parts.db'); |
| 205 | + expect(filename.value).toBe('/srv/data/parts.db'); |
| 206 | + fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ })); |
| 207 | + await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true)); |
| 208 | + expect(lastWrite(seen, 'POST', '/api/v1/datasources/test')).toMatchObject({ driver: 'sqlite', config: { filename: '/srv/data/parts.db', wal: true } }); |
| 209 | + }); |
| 210 | + |
| 211 | + it('(d) CONTROL: boxes left empty send their defaults; a switch keeps its default as its value', async () => { |
| 212 | + const seen = renderPage(); |
| 213 | + const dialog = await openNew(); |
| 214 | + expect((controlUnder('WAL')).getAttribute('aria-checked')).toBe('true'); |
| 215 | + fireEvent.click(within(dialog).getByRole('button', { name: /Test connection/ })); |
| 216 | + await waitFor(() => expect(seen.some((r) => r.url === '/api/v1/datasources/test')).toBe(true)); |
| 217 | + expect(lastWrite(seen, 'POST', '/api/v1/datasources/test').config).toEqual({ filename: ':memory:', wal: true }); |
| 218 | + |
| 219 | + await chooseDriver('PostgreSQL'); |
| 220 | + const host = controlUnder('Host') as HTMLInputElement; |
| 221 | + const port = controlUnder('Port') as HTMLInputElement; |
| 222 | + expect([host.value, host.placeholder]).toEqual(['', 'localhost']); |
| 223 | + expect([port.value, port.placeholder]).toEqual(['', '5432']); |
| 224 | + fireEvent.change(controlUnder('Name *'), { target: { value: 'warehouse' } }); |
| 225 | + fireEvent.change(controlUnder('Database'), { target: { value: 'wh' } }); |
| 226 | + // Typed then cleared counts as empty. |
| 227 | + fireEvent.change(host, { target: { value: 'db' } }); |
| 228 | + fireEvent.change(host, { target: { value: '' } }); |
| 229 | + fireEvent.click(within(dialog).getByRole('button', { name: /Create datasource/ })); |
| 230 | + await waitFor(() => expect(toast.success).toHaveBeenCalled()); |
| 231 | + expect(lastWrite(seen, 'POST', '/api/v1/datasources').config).toEqual({ host: 'localhost', port: 5432, database: 'wh' }); |
| 232 | + }); |
| 233 | + |
| 234 | + it('(e) an edit shows the stored values with no placeholder, and sends them as they stand', async () => { |
| 235 | + const stored = { name: 'parts_db', driver: 'sqlite', schemaMode: 'external', config: { filename: '/srv/data/parts.db' }, origin: 'runtime', hasSecret: false }; |
| 236 | + const seen = renderPage({ rows: [{ name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' }], stored }); |
| 237 | + fireEvent.click(await screen.findByTitle(t('engine.datasource.edit', EN))); |
| 238 | + const dialog = await screen.findByRole('dialog'); |
| 239 | + const filename = controlUnder('Filename') as HTMLInputElement; |
| 240 | + expect(filename.value).toBe('/srv/data/parts.db'); |
| 241 | + expect(filename.placeholder).toBe(''); |
| 242 | + fireEvent.click(within(dialog).getByRole('button', { name: /Save changes/ })); |
| 243 | + await waitFor(() => expect(toast.success).toHaveBeenCalled()); |
| 244 | + expect(lastWrite(seen, 'PATCH', '/api/v1/datasources/parts_db').config).toEqual({ filename: '/srv/data/parts.db' }); |
| 245 | + }); |
| 246 | +}); |
| 247 | + |
| 248 | +describe('A managed row says why it has no Sync (objectstack#30327)', () => { |
| 249 | + const rows = [ |
| 250 | + { name: 'default', driver: 'sqlite', origin: 'code', status: 'ok', schemaMode: 'managed' }, |
| 251 | + { name: 'scratch', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'managed' }, |
| 252 | + { name: 'parts_db', driver: 'sqlite', origin: 'runtime', status: 'ok', schemaMode: 'external' }, |
| 253 | + ]; |
| 254 | + const rowOf = (name: string) => { |
| 255 | + const row = screen.getByText(name, { selector: 'code' }).closest('div.flex.items-center.gap-3'); |
| 256 | + if (!(row instanceof HTMLElement)) throw new Error(`no ${name} row`); |
| 257 | + return row; |
| 258 | + }; |
| 259 | + |
| 260 | + it('(f) the managed row carries the note; the external row and the primary datasource do not', async () => { |
| 261 | + renderPage({ rows }); |
| 262 | + await screen.findByText('scratch', { selector: 'code' }); |
| 263 | + expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', EN)); |
| 264 | + expect(within(rowOf('scratch')).queryByRole('button', { name: t('engine.datasource.sync', EN) })).toBeNull(); |
| 265 | + expect(within(rowOf('parts_db')).queryByTestId('datasource-no-sync-note')).toBeNull(); |
| 266 | + expect(within(rowOf('parts_db')).getByRole('button', { name: t('engine.datasource.sync', EN) })).toBeTruthy(); |
| 267 | + expect(within(rowOf('default')).queryByTestId('datasource-no-sync-note')).toBeNull(); |
| 268 | + }); |
| 269 | + |
| 270 | + it('(g) zh: the mode sentences and the note read in Chinese', async () => { |
| 271 | + for (const key of [ |
| 272 | + 'engine.datasource.schemaModeExternal', |
| 273 | + 'engine.datasource.schemaModeManaged', |
| 274 | + 'engine.datasource.schemaModeExternalHint', |
| 275 | + 'engine.datasource.schemaModeManagedHint', |
| 276 | + 'engine.datasource.schemaModeValidateOnlyHint', |
| 277 | + 'engine.datasource.noSyncManaged', |
| 278 | + ]) { |
| 279 | + expect(t(key, ZH), key).toMatch(/\p{Script=Han}/u); |
| 280 | + expect(t(key, ZH), key).not.toBe(t(key, EN)); |
| 281 | + } |
| 282 | + renderPage({ rows, language: 'zh' }); |
| 283 | + await screen.findByText('scratch', { selector: 'code' }); |
| 284 | + expect(within(rowOf('scratch')).getByTestId('datasource-no-sync-note').textContent).toBe(t('engine.datasource.noSyncManaged', ZH)); |
| 285 | + const dialog = await openNew('zh'); |
| 286 | + expect(within(dialog).getByTestId('datasource-schema-mode-hint').textContent).toBe(t('engine.datasource.schemaModeExternalHint', ZH)); |
| 287 | + }); |
| 288 | +}); |
0 commit comments