|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21044] Over the wire: `POST /api/v1/analytics/query`, served by the real |
| 5 | + * dispatcher route, refuses a configured cube's `max` over a `text` column with |
| 6 | + * the cube door's `INVALID_FIELD` / 400 — on the native-SQL face, which served |
| 7 | + * the column's text under `fields[] { type: 'number' }`, and on the ObjectQL |
| 8 | + * face, which the engine's aggregate door refused only after the strategy had |
| 9 | + * begun — and serves the controls. |
| 10 | + * |
| 11 | + * The service door is pinned beside the implementation (`service-analytics` |
| 12 | + * `cube-measure-field-type-door.test.ts`, both faces, with read counters). |
| 13 | + * This file asks the question that pin cannot: does the route relay the |
| 14 | + * refusal's envelope as a 400 with its code, and does `fields[]` reach the wire |
| 15 | + * as the service describes it. |
| 16 | + * |
| 17 | + * The analytics service is the one `AnalyticsServicePlugin` composes over a |
| 18 | + * real `ObjectQL` engine and `SqlDriver`: `sourceFieldMeta` wired from the |
| 19 | + * engine's registry, both auto-bridges live, the cube handed in as |
| 20 | + * `AnalyticsServiceConfig.cubes` (the config key the CLI threads an app's |
| 21 | + * `analyticsCubes` into). The SQLite cell always runs; the PostgreSQL cell runs |
| 22 | + * where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. No CI step |
| 23 | + * provisions that variable for this package, so the live cell is red-capable |
| 24 | + * and un-run in CI; the PR that landed this file carries its local PostgreSQL |
| 25 | + * 16 run. The live cell owns its table, dropped before and after. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 29 | +import { ObjectQL } from '@objectstack/objectql'; |
| 30 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 31 | +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; |
| 32 | + |
| 33 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
| 34 | + |
| 35 | +const OBJECT = 'os21044_route_ledger'; |
| 36 | + |
| 37 | +const LEDGER = { |
| 38 | + name: OBJECT, |
| 39 | + label: 'Cube measure route ledger', |
| 40 | + fields: { |
| 41 | + note: { name: 'note', type: 'text' as const }, |
| 42 | + opened_at: { name: 'opened_at', type: 'datetime' as const }, |
| 43 | + amount: { name: 'amount', type: 'number' as const }, |
| 44 | + }, |
| 45 | +}; |
| 46 | + |
| 47 | +const ROWS = [ |
| 48 | + { id: 'r1', note: 'x', opened_at: '2026-01-02T03:04:05.000Z', amount: 10 }, |
| 49 | + { id: 'r2', note: 'y', opened_at: '2026-03-04T05:06:07.000Z', amount: 32 }, |
| 50 | +] as const; |
| 51 | + |
| 52 | +const CUBE = { |
| 53 | + name: 'os21044_route_cube', |
| 54 | + title: 'Cube measure route cube', |
| 55 | + sql: OBJECT, |
| 56 | + public: true, |
| 57 | + measures: { |
| 58 | + max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' }, |
| 59 | + max_opened: { type: 'max', sql: 'opened_at', label: 'Latest opening (datetime)' }, |
| 60 | + max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' }, |
| 61 | + }, |
| 62 | + dimensions: {}, |
| 63 | +}; |
| 64 | + |
| 65 | +interface Cell { |
| 66 | + id: 'sqlite' | 'pg'; |
| 67 | + label: string; |
| 68 | + env: string | null; |
| 69 | + config: () => Record<string, unknown> | null; |
| 70 | +} |
| 71 | + |
| 72 | +const CELLS: readonly Cell[] = [ |
| 73 | + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, |
| 74 | + { |
| 75 | + id: 'pg', |
| 76 | + label: 'live postgres', |
| 77 | + env: 'OS_TEST_POSTGRES_URL', |
| 78 | + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), |
| 79 | + }, |
| 80 | +]; |
| 81 | + |
| 82 | +const FACES = ['native', 'objectql'] as const; |
| 83 | +type Face = (typeof FACES)[number]; |
| 84 | + |
| 85 | +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; |
| 86 | + |
| 87 | +// ── harness (the shape `analytics-authored-cube-format-granularity.test.ts` uses) ── |
| 88 | + |
| 89 | +type Handler = (req: unknown, res: unknown) => unknown; |
| 90 | + |
| 91 | +function makeFakeServer() { |
| 92 | + const handlers: Record<string, Handler> = {}; |
| 93 | + const rec = (verb: string) => (path: string, handler: Handler) => { |
| 94 | + handlers[`${verb} ${path}`] = handler; |
| 95 | + }; |
| 96 | + return { |
| 97 | + handlers, |
| 98 | + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, |
| 99 | + }; |
| 100 | +} |
| 101 | + |
| 102 | +function makeCtx(fakeServer: unknown, analytics: unknown) { |
| 103 | + const kernel = { |
| 104 | + getService: (name: string) => (name === 'analytics' ? analytics : undefined), |
| 105 | + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined), |
| 106 | + }; |
| 107 | + return { |
| 108 | + getKernel: () => kernel, |
| 109 | + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), |
| 110 | + environmentId: undefined, |
| 111 | + logger: quiet, |
| 112 | + hook: () => {}, |
| 113 | + on: () => {}, |
| 114 | + } as any; |
| 115 | +} |
| 116 | + |
| 117 | +function makeRes() { |
| 118 | + const res: any = { |
| 119 | + statusCode: undefined as number | undefined, |
| 120 | + body: undefined as any, |
| 121 | + status(c: number) { res.statusCode = c; return res; }, |
| 122 | + header() { return res; }, |
| 123 | + json(b: unknown) { res.body = b; return res; }, |
| 124 | + }; |
| 125 | + return res; |
| 126 | +} |
| 127 | + |
| 128 | +for (const cell of CELLS) { |
| 129 | + const config = cell.config(); |
| 130 | + describe.skipIf(!config)( |
| 131 | + `[#21044] POST /api/v1/analytics/query — a cube measure the aggregate × field-type table refuses is a 400 — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, |
| 132 | + () => { |
| 133 | + let driver: any; |
| 134 | + let engine: ObjectQL; |
| 135 | + const post: Partial<Record<Face, (body: unknown) => Promise<{ status: number; body: any }>>> = {}; |
| 136 | + |
| 137 | + const dropTable = async () => { |
| 138 | + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); |
| 139 | + }; |
| 140 | + |
| 141 | + beforeAll(async () => { |
| 142 | + driver = new SqlDriver(config as any); |
| 143 | + await dropTable(); |
| 144 | + engine = new ObjectQL({ logger: quiet } as any); |
| 145 | + engine.registerDriver(driver, true); |
| 146 | + await engine.init(); |
| 147 | + engine.registry.registerObject(LEDGER as any); |
| 148 | + await engine.syncSchemas(); |
| 149 | + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); |
| 150 | + |
| 151 | + for (const [face, caps] of [ |
| 152 | + ['native', undefined], |
| 153 | + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], |
| 154 | + ] as const) { |
| 155 | + const registered: Record<string, unknown> = {}; |
| 156 | + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ |
| 157 | + getService: (name: string) => (name === 'data' ? engine : registered[name]), |
| 158 | + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 159 | + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 160 | + hook: () => {}, |
| 161 | + logger: quiet, |
| 162 | + } as never); |
| 163 | + const { server, handlers } = makeFakeServer(); |
| 164 | + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); |
| 165 | + await plugin.start?.(makeCtx(server, registered.analytics)); |
| 166 | + const handler = handlers['POST /api/v1/analytics/query']; |
| 167 | + expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function'); |
| 168 | + post[face] = async (body) => { |
| 169 | + const res = makeRes(); |
| 170 | + // What the wire carries: JSON, both ways. |
| 171 | + await handler({ body: JSON.parse(JSON.stringify(body)), query: {} }, res); |
| 172 | + return { status: res.statusCode ?? 200, body: JSON.parse(JSON.stringify(res.body ?? null)) }; |
| 173 | + }; |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + afterAll(async () => { |
| 178 | + await dropTable(); |
| 179 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 180 | + }); |
| 181 | + |
| 182 | + for (const face of FACES) { |
| 183 | + it(`${face}: max over a text column answers 400 INVALID_FIELD, and nothing is served`, async () => { |
| 184 | + const res = await post[face]!({ cube: CUBE.name, measures: ['max_note'] }); |
| 185 | + expect(res.status, JSON.stringify(res.body)).toBe(400); |
| 186 | + expect(res.body.success).toBe(false); |
| 187 | + expect(res.body.error.code).toBe('INVALID_FIELD'); |
| 188 | + expect(res.body.error.httpStatus).toBe(400); |
| 189 | + expect(res.body.data).toBeUndefined(); |
| 190 | + }); |
| 191 | + |
| 192 | + it(`${face}: the control — max over a number column is served, a number typed number`, async () => { |
| 193 | + const res = await post[face]!({ cube: CUBE.name, measures: ['max_amount'] }); |
| 194 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 195 | + const fields = res.body.data.fields as Array<{ name: string; type: string }>; |
| 196 | + expect(res.body.data.rows[0].max_amount).toBe(32); |
| 197 | + expect(fields.find((f) => f.name === 'max_amount')?.type).toBe('number'); |
| 198 | + }); |
| 199 | + |
| 200 | + it(`${face}: an accepted temporal pair is served and reaches the wire typed time`, async () => { |
| 201 | + const res = await post[face]!({ cube: CUBE.name, measures: ['max_opened'] }); |
| 202 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 203 | + const fields = res.body.data.fields as Array<{ name: string; type: string }>; |
| 204 | + expect(res.body.data.rows[0].max_opened).not.toBeNull(); |
| 205 | + expect(fields.find((f) => f.name === 'max_opened')?.type).toBe('time'); |
| 206 | + }); |
| 207 | + } |
| 208 | + }, |
| 209 | + ); |
| 210 | +} |
0 commit comments