|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Integration test — atomic cross-object batch through the typed SDK |
| 5 | + * (`client.data.batchTransaction`, issue #1604 / ADR-0034 item 4). |
| 6 | + * |
| 7 | + * Boots a real Hono server (LiteKernel + ObjectQLPlugin + createRestApiPlugin, |
| 8 | + * same pattern as client.environment-scoping.test.ts) and proves the full |
| 9 | + * client → hono → rest → engine chain: |
| 10 | + * 1. Parent + child with `{ $ref: 0 }` commit in one request and the child's |
| 11 | + * FK equals the parent's generated id. |
| 12 | + * 2. The environment-scoped mirror resolves `/environments/:id/batch`. |
| 13 | + * 3. An unresolvable `$ref` rejects and the client error carries |
| 14 | + * `code: 'BATCH_UNRESOLVED_REF'` (error-mapping proof). |
| 15 | + * 4. `atomic: false` is rejected with 400 BATCH_NOT_ATOMIC — the contract |
| 16 | + * reason the SDK method exposes no `atomic` flag. |
| 17 | + * |
| 18 | + * NOTE on atomicity: the InMemoryDriver's `transaction()` is a passthrough |
| 19 | + * (no rollback), so all-or-nothing semantics are NOT asserted here — they are |
| 20 | + * covered by objectql/src/engine-ambient-transaction.test.ts and |
| 21 | + * rest/src/rest-batch-endpoint.test.ts against transactional drivers. |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 25 | +import { LiteKernel } from '@objectstack/core'; |
| 26 | +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; |
| 27 | +import { InMemoryDriver } from '@objectstack/driver-memory'; |
| 28 | +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; |
| 29 | +import { createRestApiPlugin } from '@objectstack/runtime'; |
| 30 | +import { ObjectStackClient } from './index'; |
| 31 | + |
| 32 | +describe('data.batchTransaction (live Hono, #1604)', () => { |
| 33 | + let baseUrl: string; |
| 34 | + let kernel: LiteKernel; |
| 35 | + let client: ObjectStackClient; |
| 36 | + |
| 37 | + beforeAll(async () => { |
| 38 | + kernel = new LiteKernel(); |
| 39 | + kernel.use(new ObjectQLPlugin()); |
| 40 | + |
| 41 | + kernel.use( |
| 42 | + new HonoServerPlugin({ |
| 43 | + port: 0, |
| 44 | + // Skip hardcoded hono CRUD routes so createRestApiPlugin owns |
| 45 | + // route registration (including the root /batch route). |
| 46 | + registerStandardEndpoints: false, |
| 47 | + }), |
| 48 | + ); |
| 49 | + |
| 50 | + kernel.use( |
| 51 | + createRestApiPlugin({ |
| 52 | + api: { |
| 53 | + api: { |
| 54 | + // Routing test, no auth stack mounted — opt out of the |
| 55 | + // secure-by-default anonymous deny (ADR-0056 D2). |
| 56 | + requireAuth: false, |
| 57 | + enableProjectScoping: true, |
| 58 | + projectResolution: 'auto', |
| 59 | + } as any, |
| 60 | + }, |
| 61 | + }), |
| 62 | + ); |
| 63 | + |
| 64 | + await kernel.bootstrap(); |
| 65 | + |
| 66 | + const ql = kernel.getService<ObjectQL>('objectql'); |
| 67 | + ql.registerDriver(new InMemoryDriver(), true); |
| 68 | + |
| 69 | + ql.registerObject({ |
| 70 | + name: 'project', |
| 71 | + label: 'Project', |
| 72 | + fields: { |
| 73 | + name: { type: 'text', label: 'Name' }, |
| 74 | + }, |
| 75 | + }); |
| 76 | + ql.registerObject({ |
| 77 | + name: 'task', |
| 78 | + label: 'Task', |
| 79 | + fields: { |
| 80 | + title: { type: 'text', label: 'Title' }, |
| 81 | + project: { type: 'lookup', reference_to: 'project', label: 'Project' }, |
| 82 | + }, |
| 83 | + }); |
| 84 | + |
| 85 | + const httpServer = kernel.getService<any>('http.server'); |
| 86 | + baseUrl = `http://localhost:${httpServer.getPort()}`; |
| 87 | + client = new ObjectStackClient({ baseUrl }); |
| 88 | + }, 30_000); |
| 89 | + |
| 90 | + afterAll(async () => { |
| 91 | + if (kernel) { |
| 92 | + await Promise.race([ |
| 93 | + kernel.shutdown(), |
| 94 | + new Promise<void>((resolve) => setTimeout(resolve, 10_000)), |
| 95 | + ]); |
| 96 | + } |
| 97 | + }, 30_000); |
| 98 | + |
| 99 | + it('creates parent + child in one request; { $ref: 0 } resolves to the parent id', async () => { |
| 100 | + const { results } = await client.data.batchTransaction([ |
| 101 | + { object: 'project', action: 'create', data: { name: 'Apollo' } }, |
| 102 | + { object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } }, |
| 103 | + ]); |
| 104 | + |
| 105 | + expect(results).toHaveLength(2); |
| 106 | + const parent = results[0] as any; |
| 107 | + const child = results[1] as any; |
| 108 | + expect(parent.id).toBeDefined(); |
| 109 | + expect(child.project).toBe(parent.id); |
| 110 | + |
| 111 | + // Both rows are readable afterwards through the ordinary data API. |
| 112 | + const storedChild = await client.data.get<any>('task', child.id); |
| 113 | + expect((storedChild as any).project ?? (storedChild as any).record?.project).toBeDefined(); |
| 114 | + }); |
| 115 | + |
| 116 | + it('is mirrored on the environment-scoped client (/environments/:id/batch)', async () => { |
| 117 | + const scoped = client.project('proj-alpha'); |
| 118 | + const { results } = await scoped.data.batchTransaction([ |
| 119 | + { object: 'project', action: 'create', data: { name: 'Scoped' } }, |
| 120 | + ]); |
| 121 | + expect((results[0] as any).id).toBeDefined(); |
| 122 | + }); |
| 123 | + |
| 124 | + it('rejects an unresolvable $ref with BATCH_UNRESOLVED_REF surfaced on the client error', async () => { |
| 125 | + let caught: any; |
| 126 | + try { |
| 127 | + await client.data.batchTransaction([ |
| 128 | + { object: 'task', action: 'create', data: { title: 'orphan', project: { $ref: 5 } } }, |
| 129 | + ]); |
| 130 | + } catch (e) { |
| 131 | + caught = e; |
| 132 | + } |
| 133 | + expect(caught).toBeDefined(); |
| 134 | + expect(caught.httpStatus).toBe(400); |
| 135 | + expect(caught.code).toBe('BATCH_UNRESOLVED_REF'); |
| 136 | + }); |
| 137 | + |
| 138 | + it('rejects atomic:false with 400 BATCH_NOT_ATOMIC (why the SDK has no atomic flag)', async () => { |
| 139 | + const res = await fetch(`${baseUrl}/api/v1/batch`, { |
| 140 | + method: 'POST', |
| 141 | + headers: { 'Content-Type': 'application/json' }, |
| 142 | + body: JSON.stringify({ |
| 143 | + operations: [{ object: 'project', action: 'create', data: { name: 'nope' } }], |
| 144 | + atomic: false, |
| 145 | + }), |
| 146 | + }); |
| 147 | + expect(res.status).toBe(400); |
| 148 | + const body = await res.json(); |
| 149 | + expect(body.code).toBe('BATCH_NOT_ATOMIC'); |
| 150 | + }); |
| 151 | +}); |
0 commit comments