|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | 4 | import { actionBodyRunnerFactory, QuickJSScriptRunner } from '@objectstack/runtime'; |
5 | 5 |
|
6 | | -import { allActions, MarkDoneAction } from '../src/ui/actions/index.js'; |
| 6 | +import { allActions, MarkDoneAction, PortfolioSnapshotAction } from '../src/ui/actions/index.js'; |
7 | 7 |
|
8 | 8 | /** |
9 | 9 | * Execution-path coverage for declared actions. |
@@ -84,3 +84,73 @@ describe('showcase actions — executability', () => { |
84 | 84 | expect(handler).toBeUndefined(); |
85 | 85 | }); |
86 | 86 | }); |
| 87 | + |
| 88 | +/** |
| 89 | + * The object-less ("global") action specimen — framework#3913. |
| 90 | + * |
| 91 | + * #3913 was filed because object-less actions were unreachable: registered |
| 92 | + * under the canonical `'global'` key, looked up under `'*'`. Its follow-up then |
| 93 | + * found that `POST /api/v1/actions//:action` — the empty-object-segment URL the |
| 94 | + * issue was actually filed against — had no route registration at all. Both |
| 95 | + * were fixed with **no live specimen**: the showcase is the "one specimen of |
| 96 | + * everything" app and every action in it declared an `objectName`, so nothing |
| 97 | + * exercised the object-less dispatch path end to end. |
| 98 | + * |
| 99 | + * These tests pin the two properties that make it a specimen. The first is the |
| 100 | + * one that silently rots: adding an `objectName` to this action would still |
| 101 | + * build, still pass `coverage.test.ts`, and still work — while quietly removing |
| 102 | + * the app's only coverage of object-less dispatch. |
| 103 | + */ |
| 104 | +describe('showcase actions — the object-less (`global`) specimen', () => { |
| 105 | + const runner = new QuickJSScriptRunner(); |
| 106 | + |
| 107 | + it('declares no object, so it keys at `global` (framework#3913)', () => { |
| 108 | + // This mirrors ObjectQLPlugin.actionObjectKey / AppPlugin's |
| 109 | + // `action.object || 'global'`: neither field set → the 'global' bucket. |
| 110 | + const a = PortfolioSnapshotAction as { objectName?: string; object?: string }; |
| 111 | + expect(a.objectName).toBeUndefined(); |
| 112 | + expect(a.object).toBeUndefined(); |
| 113 | + |
| 114 | + // ...and it is the ONLY one, so this test is what keeps the specimen alive. |
| 115 | + const objectLess = allActions.filter((x) => { |
| 116 | + const y = x as { objectName?: string; object?: string }; |
| 117 | + return !y.objectName && !y.object; |
| 118 | + }); |
| 119 | + expect(objectLess.map((x) => x.name)).toEqual(['showcase_portfolio_snapshot']); |
| 120 | + }); |
| 121 | + |
| 122 | + it('counts across several objects via the sandboxed body', async () => { |
| 123 | + // An object-less action has no record and no single object to hang off — |
| 124 | + // this body reads three, which is why it cannot be given an `objectName`. |
| 125 | + const counts: Record<string, number> = { |
| 126 | + showcase_account: 15, |
| 127 | + showcase_project: 5, |
| 128 | + showcase_invoice: 13, |
| 129 | + }; |
| 130 | + const seen: string[] = []; |
| 131 | + const ql = { |
| 132 | + object: (object: string) => ({ |
| 133 | + count: async () => { |
| 134 | + seen.push(object); |
| 135 | + return counts[object] ?? 0; |
| 136 | + }, |
| 137 | + }), |
| 138 | + }; |
| 139 | + |
| 140 | + const factory = actionBodyRunnerFactory(runner, { ql, appId: 'showcase' }); |
| 141 | + const handler = factory(PortfolioSnapshotAction as never); |
| 142 | + expect(typeof handler).toBe('function'); |
| 143 | + |
| 144 | + // No recordId, no record — the object-less invocation shape. |
| 145 | + const result = await handler!({ params: {}, user: { id: 'u1' } }); |
| 146 | + |
| 147 | + expect(seen).toEqual(['showcase_account', 'showcase_project', 'showcase_invoice']); |
| 148 | + expect(result).toEqual({ |
| 149 | + ok: true, |
| 150 | + scope: 'global', |
| 151 | + accounts: 15, |
| 152 | + projects: 5, |
| 153 | + invoices: 13, |
| 154 | + }); |
| 155 | + }); |
| 156 | +}); |
0 commit comments