Skip to content

Commit be5c1a6

Browse files
committed
test(service-analytics,runtime): pin the cube door's aggregate x field-type judgment (red)
Pins first, ahead of the fix: a configured cube measure whose aggregate the aggregate x field-type table refuses for its column's declared type is refused INVALID_FIELD / 400 before either strategy reads anything; max over a number column is served typed number (the control); min / max over a temporal column is described time. The #20889 control that read max over a text column back is flipped to the refusal. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2821e9f commit be5c1a6

3 files changed

Lines changed: 539 additions & 11 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)