|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `analytics_cube.description`, `measures.description`, `dimensions.description` |
| 5 | + * and `measures.format` on the discovery door — `getMeta()`, which |
| 6 | + * `GET /api/v1/analytics/meta` hands to `success()` verbatim. |
| 7 | + * |
| 8 | + * The `CubeMeta` projection carried `{ name, type, title }` per member and |
| 9 | + * `{ name, title }` per cube, so an authored description reached no reader at |
| 10 | + * all, and a measure's `format` reached only the query door's `fields[]`. |
| 11 | + * |
| 12 | + * What this file pins: |
| 13 | + * - an authored cube's descriptions (cube, measure, dimension) and a measure's |
| 14 | + * `format` are published exactly as written; |
| 15 | + * - a definition that declares none of them publishes no key — the projection |
| 16 | + * copies, it never fills in; |
| 17 | + * - a compiled dataset's cube publishes the `format` the dataset compiler |
| 18 | + * copies from each dataset measure, and no `description`: the compiler |
| 19 | + * writes none, so there is none to publish; |
| 20 | + * - a hidden cube stays hidden, descriptions or not. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, vi } from 'vitest'; |
| 24 | +import { CubeSchema, type Cube } from '@objectstack/spec/data'; |
| 25 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 26 | +import { AnalyticsService } from '../analytics-service.js'; |
| 27 | + |
| 28 | +const silentLogger = { |
| 29 | + info: vi.fn(), |
| 30 | + debug: vi.fn(), |
| 31 | + warn: vi.fn(), |
| 32 | + error: vi.fn(), |
| 33 | + child: vi.fn().mockReturnThis(), |
| 34 | +} as any; |
| 35 | + |
| 36 | +/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */ |
| 37 | +const described: Cube = CubeSchema.parse({ |
| 38 | + name: 'orders', |
| 39 | + title: 'Orders', |
| 40 | + description: 'Every order placed in the shop, one row per order.', |
| 41 | + sql: 'shop_order', |
| 42 | + measures: { |
| 43 | + revenue: { |
| 44 | + label: 'Revenue', |
| 45 | + description: 'Sum of order amounts, in the order currency.', |
| 46 | + type: 'sum', |
| 47 | + sql: 'amount', |
| 48 | + format: '$0,0.00', |
| 49 | + }, |
| 50 | + margin: { label: 'Margin', type: 'avg', sql: 'margin', format: '0.0%' }, |
| 51 | + count: { label: 'Orders', description: 'Number of orders.', type: 'count', sql: '*' }, |
| 52 | + }, |
| 53 | + dimensions: { |
| 54 | + status: { label: 'Status', description: 'Fulfilment status of the order.', type: 'string', sql: 'status' }, |
| 55 | + placed_at: { label: 'Placed', type: 'time', sql: 'placed_at' }, |
| 56 | + }, |
| 57 | +}); |
| 58 | + |
| 59 | +/** The same kind of cube with no description and no format anywhere. */ |
| 60 | +const bare: Cube = CubeSchema.parse({ |
| 61 | + name: 'refunds', |
| 62 | + sql: 'shop_refund', |
| 63 | + measures: { count: { label: 'Refunds', type: 'count', sql: '*' } }, |
| 64 | + dimensions: { reason: { label: 'Reason', type: 'string', sql: 'reason' } }, |
| 65 | +}); |
| 66 | + |
| 67 | +function service(cubes: Cube[]) { |
| 68 | + return new AnalyticsService({ logger: silentLogger, cubes }); |
| 69 | +} |
| 70 | + |
| 71 | +describe('getMeta — an authored cube publishes its descriptions and measure formats', () => { |
| 72 | + it('copies the cube, measure and dimension descriptions and each measure format as written', async () => { |
| 73 | + const [cube] = await service([described]).getMeta('orders'); |
| 74 | + |
| 75 | + expect(cube).toEqual({ |
| 76 | + name: 'orders', |
| 77 | + title: 'Orders', |
| 78 | + description: 'Every order placed in the shop, one row per order.', |
| 79 | + measures: [ |
| 80 | + { |
| 81 | + name: 'orders.revenue', |
| 82 | + type: 'sum', |
| 83 | + title: 'Revenue', |
| 84 | + description: 'Sum of order amounts, in the order currency.', |
| 85 | + format: '$0,0.00', |
| 86 | + }, |
| 87 | + { name: 'orders.margin', type: 'avg', title: 'Margin', format: '0.0%' }, |
| 88 | + { name: 'orders.count', type: 'count', title: 'Orders', description: 'Number of orders.' }, |
| 89 | + ], |
| 90 | + dimensions: [ |
| 91 | + { name: 'orders.status', type: 'string', title: 'Status', description: 'Fulfilment status of the order.' }, |
| 92 | + { name: 'orders.placed_at', type: 'time', title: 'Placed' }, |
| 93 | + ], |
| 94 | + }); |
| 95 | + }); |
| 96 | + |
| 97 | + it('publishes no key a definition does not declare', async () => { |
| 98 | + const [cube] = await service([bare]).getMeta('refunds'); |
| 99 | + |
| 100 | + expect(cube).not.toHaveProperty('description'); |
| 101 | + expect(cube.measures[0]).not.toHaveProperty('description'); |
| 102 | + expect(cube.measures[0]).not.toHaveProperty('format'); |
| 103 | + expect(cube.dimensions[0]).not.toHaveProperty('description'); |
| 104 | + // What the projection did publish before this change is unchanged. |
| 105 | + expect(cube.measures[0]).toEqual({ name: 'refunds.count', type: 'count', title: 'Refunds' }); |
| 106 | + expect(cube.dimensions[0]).toEqual({ name: 'refunds.reason', type: 'string', title: 'Reason' }); |
| 107 | + }); |
| 108 | + |
| 109 | + it('answers the listing and the by-name lookup alike', async () => { |
| 110 | + const svc = service([described, bare]); |
| 111 | + const listed = (await svc.getMeta()).find((c) => c.name === 'orders'); |
| 112 | + const [byName] = await svc.getMeta('orders'); |
| 113 | + expect(listed).toEqual(byName); |
| 114 | + expect(listed?.description).toBe('Every order placed in the shop, one row per order.'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('still omits a cube declared `public: false`, whatever it describes', async () => { |
| 118 | + const hidden = CubeSchema.parse({ ...described, name: 'hidden_orders', public: false }); |
| 119 | + const svc = service([hidden]); |
| 120 | + expect(await svc.getMeta()).toEqual([]); |
| 121 | + expect(await svc.getMeta('hidden_orders')).toEqual([]); |
| 122 | + }); |
| 123 | +}); |
| 124 | + |
| 125 | +describe('getMeta — a compiled dataset cube publishes what the compiler wrote', () => { |
| 126 | + const dataset = DatasetSchema.parse({ |
| 127 | + name: 'order_metrics', |
| 128 | + label: 'Order metrics', |
| 129 | + description: 'Order KPIs for the sales dashboard.', |
| 130 | + object: 'shop_order', |
| 131 | + include: [], |
| 132 | + dimensions: [{ name: 'status', field: 'status', type: 'string' }], |
| 133 | + measures: [ |
| 134 | + { name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0' }, |
| 135 | + { name: 'count', aggregate: 'count' }, |
| 136 | + ], |
| 137 | + }); |
| 138 | + |
| 139 | + it('carries each dataset measure `format`, and no description (the compiler writes none)', async () => { |
| 140 | + const svc = service([]); |
| 141 | + svc.registerDataset(dataset); |
| 142 | + const [cube] = await svc.getMeta('order_metrics'); |
| 143 | + |
| 144 | + expect(cube.measures.find((m) => m.name === 'order_metrics.revenue')?.format).toBe('$0,0'); |
| 145 | + expect(cube.measures.find((m) => m.name === 'order_metrics.count')).not.toHaveProperty('format'); |
| 146 | + // The dataset's own `description` is not copied onto the cube it compiles to |
| 147 | + // (`dataset-compiler.ts#compileDataset`), and a dataset measure or dimension |
| 148 | + // has no `description` key to copy. Nothing is filled in on this path. |
| 149 | + expect(cube).not.toHaveProperty('description'); |
| 150 | + for (const member of [...cube.measures, ...cube.dimensions]) { |
| 151 | + expect(member).not.toHaveProperty('description'); |
| 152 | + } |
| 153 | + }); |
| 154 | +}); |
0 commit comments