|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// END-TO-END gate for #30773: a dataset whose DIMENSION groups by a formula |
| 4 | +// field is refused where it is authored — the runtime save (`PUT /meta/dataset`, |
| 5 | +// the gate Studio, REST and MCP share) answers `422` naming |
| 6 | +// `dimension-virtual-field-refused`, and the dataset compile behind |
| 7 | +// `POST /analytics/dataset/query` answers `400 DATASET_INVALID` naming the |
| 8 | +// dimension, whatever the selection — proven on the stock showcase, through the |
| 9 | +// real stack. |
| 10 | +// |
| 11 | +// `showcase_project.budget_remaining` is a `formula` (`returnType: 'currency'`): |
| 12 | +// computed on read, no column. Measured on `main` `2d04cd2b` before this card, |
| 13 | +// such a dataset compiled and registered, a query selecting only its stored |
| 14 | +// dimension was served, and only a query grouping by the formula was refused |
| 15 | +// (#29304, `400 INVALID_FIELD` at `groupBy.0`) — in front of whoever opened |
| 16 | +// the tile, not the author. |
| 17 | +// |
| 18 | +// Controls: the same dataset without the formula dimension saves and queries. |
| 19 | + |
| 20 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 21 | +import { type VerifyStack } from '@objectstack/verify'; |
| 22 | +import { bootShowcase } from './showcase-boot.js'; |
| 23 | + |
| 24 | +const OBJECT = 'showcase_project'; |
| 25 | +const SAVED = 'os30773_project_by_formula'; |
| 26 | +const SAVED_CONTROL = 'os30773_project_by_status'; |
| 27 | + |
| 28 | +const dataset = (name: string, withFormula: boolean) => ({ |
| 29 | + name, |
| 30 | + label: 'Projects', |
| 31 | + object: OBJECT, |
| 32 | + dimensions: [ |
| 33 | + { name: 'status', label: 'Status', field: 'status', type: 'string' }, |
| 34 | + ...(withFormula ? [{ name: 'remaining', label: 'Remaining', field: 'budget_remaining', type: 'number' }] : []), |
| 35 | + ], |
| 36 | + measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }], |
| 37 | +}); |
| 38 | + |
| 39 | +describe('dogfood: a dataset dimension over a formula field is refused at authoring (#30773)', () => { |
| 40 | + let stack: VerifyStack; |
| 41 | + let token: string; |
| 42 | + |
| 43 | + beforeAll(async () => { |
| 44 | + stack = await bootShowcase(); |
| 45 | + token = await stack.signIn(); |
| 46 | + }, 180_000); |
| 47 | + |
| 48 | + afterAll(async () => { |
| 49 | + await stack?.stop?.(); |
| 50 | + }); |
| 51 | + |
| 52 | + async function call(method: 'PUT' | 'POST', path: string, body: unknown) { |
| 53 | + const res = await stack.apiAs(token, method, path, body); |
| 54 | + return { status: res.status, body: (await res.json()) as Record<string, any> }; |
| 55 | + } |
| 56 | + |
| 57 | + it('PUT /meta/dataset: the save is refused 422, naming dimension-virtual-field-refused at the dimension', async () => { |
| 58 | + const res = await call('PUT', `/meta/dataset/${SAVED}`, dataset(SAVED, true)); |
| 59 | + expect(res.status, JSON.stringify(res.body)).toBe(422); |
| 60 | + const text = JSON.stringify(res.body); |
| 61 | + expect(text).toContain('dimension-virtual-field-refused'); |
| 62 | + expect(text).toContain('dimensions[1].field'); |
| 63 | + expect(text).toContain('budget_remaining'); |
| 64 | + }); |
| 65 | + |
| 66 | + it('CONTROL PUT /meta/dataset: the same dataset over a stored dimension saves', async () => { |
| 67 | + const res = await call('PUT', `/meta/dataset/${SAVED_CONTROL}`, dataset(SAVED_CONTROL, false)); |
| 68 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 69 | + }); |
| 70 | + |
| 71 | + for (const dimensions of [['remaining'], ['status']]) { |
| 72 | + it(`POST /analytics/dataset/query (inline, selecting ${dimensions.join()}): 400 DATASET_INVALID naming the dimension and the remedy`, async () => { |
| 73 | + const res = await call('POST', '/analytics/dataset/query', { |
| 74 | + dataset: dataset('os30773_inline', true), |
| 75 | + selection: { dimensions, measures: ['cnt'] }, |
| 76 | + }); |
| 77 | + expect(res.status, JSON.stringify(res.body)).toBe(400); |
| 78 | + const error = res.body.error ?? res.body; |
| 79 | + expect(error.code, JSON.stringify(res.body)).toBe('DATASET_INVALID'); |
| 80 | + const message = String(error.message); |
| 81 | + expect(message).toContain( |
| 82 | + 'dataset "os30773_inline" dimension "remaining" (dimensions.1) groups by field "budget_remaining", ' + |
| 83 | + `which object "${OBJECT}" declares as \`formula\``, |
| 84 | + ); |
| 85 | + expect(message).toContain(`Denormalise the value onto "${OBJECT}" (a stored field, written when the source changes)`); |
| 86 | + }); |
| 87 | + } |
| 88 | + |
| 89 | + it('CONTROL POST /analytics/dataset/query: the dataset without the formula dimension is served', async () => { |
| 90 | + const res = await call('POST', '/analytics/dataset/query', { |
| 91 | + dataset: dataset('os30773_inline_control', false), |
| 92 | + selection: { dimensions: ['status'], measures: ['cnt'] }, |
| 93 | + }); |
| 94 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 95 | + }); |
| 96 | +}); |
0 commit comments