Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
9c7a747
fix(request): use update_columns:[] on nested target on_conflict
mosoriob May 8, 2026
f6bcfc6
feat(nested-tree): add WriteTree types and validation caps
mosoriob May 9, 2026
cd3ccaa
feat(nested-tree): buildTree for single-level junction relationships
mosoriob May 9, 2026
7578b32
test(nested-tree): assert multi-level recursion through junction edges
mosoriob May 9, 2026
83a5a4c
feat(nested-tree): add childFk edge branch with recursion
mosoriob May 9, 2026
b3f41f8
test(nested-tree): cover all 6 validation rules + sibling-repeat allo…
mosoriob May 9, 2026
1369959
feat(mutation-compiler): compilePost with dynamic update_columns and …
mosoriob May 9, 2026
dd39fb3
feat(mutation-compiler): compilePut with replace-subtree semantics an…
mosoriob May 9, 2026
168efce
fix(mutation-compiler): clear childFk uses _nin (orphan-prune) not _in
mosoriob May 9, 2026
e764653
fix(mutation-compiler): dedup update_columns to avoid Hasura duplicat…
mosoriob May 9, 2026
d3f6fd9
feat(resource-registry): add optional targetFkColumn override for jun…
mosoriob May 9, 2026
54d4f3e
refactor(service): create() uses buildTree + compilePost pipeline
mosoriob May 9, 2026
af3dd91
fix(mutation-compiler): use hasuraRelName for childFk nested insert key
mosoriob May 9, 2026
2afbe56
refactor(service): update() uses buildTree + compilePut pipeline
mosoriob May 9, 2026
f73b49d
refactor(request): remove buildJunctionInserts (replaced by nested-tr…
mosoriob May 9, 2026
687cdfc
test(integration): nested write round-trip, replace-subtree, bug-087 …
mosoriob May 9, 2026
fad2328
chore: bump to v2.1.0; openapi.yaml requires object form for relation…
mosoriob May 9, 2026
b0ae946
test: add explicit vitest config excluding future e2e dir
mosoriob May 9, 2026
41a0069
test: widen vitest include to src/**/*.test.ts to keep mapper tests
mosoriob May 9, 2026
3ff740c
test: add vitest e2e config and test:e2e script
mosoriob May 9, 2026
eeab741
feat(hasura/client): MINT_E2E_MODE flips writeClient to admin-secret …
mosoriob May 9, 2026
7be5c61
test(hasura-client): unstubAllGlobals in afterEach; document env orde…
mosoriob May 9, 2026
af9829d
test(e2e): add helpers — uniqueId, trackId, inject, cleanup
mosoriob May 9, 2026
4019df1
test(e2e): add setup — env defaults, app builder, Hasura health-check
mosoriob May 9, 2026
11309fc
test: add e2e flat-entity smoke test for persons
mosoriob May 9, 2026
1539905
fix: emit hasuraRelName as junction-array key on parent insert object
mosoriob May 9, 2026
78b3db3
fix(e2e): strip Content-Type from cleanup DELETE request
mosoriob May 9, 2026
65af8f2
fix: emit FK column directly for link-only junction children
mosoriob May 9, 2026
639fdf0
test(e2e): junction-e2e — bug-087 label-clobber regression
mosoriob May 9, 2026
e237733
test(e2e): junction round-trip GET after POST
mosoriob May 9, 2026
64a3b2e
fix: thread parentFkColumn into compilePut junction insert objects
mosoriob May 9, 2026
1ebd744
test(e2e): junction PUT replace set
mosoriob May 9, 2026
36c4e68
test(e2e): junction PUT [] clears all links
mosoriob May 9, 2026
cf0ef6d
test(e2e): junction POST dedup duplicates
mosoriob May 9, 2026
efd4c0a
test(e2e): junction POST rejects unknown FK target
mosoriob May 9, 2026
ba10e34
fix: do not set childFk column explicitly on nested POST rows
mosoriob May 9, 2026
e2ce05c
test(e2e): nested POST inline hasVersion (bug-089 target)
mosoriob May 9, 2026
d5e6c95
test(e2e): nested POST 3-deep sw→ver→cfg (bug-089 target)
mosoriob May 9, 2026
b2a3010
test(e2e): nested PUT updates child label only (bug-089 target)
mosoriob May 9, 2026
b092279
test(e2e): nested POST mixed inline+ref preserves existing data (bug-…
mosoriob May 9, 2026
901179a
test(e2e): nested PUT replaces child set (bug-089 target)
mosoriob May 9, 2026
47f86ca
docs(skill): add run-e2e-hasura project-local skill
mosoriob May 9, 2026
d3e1f4a
docs(claude): point to run-e2e-hasura skill
mosoriob May 9, 2026
73f987c
fix(nested-writes): route link-only childFk refs to aliased FK update
mosoriob May 9, 2026
b68329b
fix(test): widen on_conflict type to include constraint field
mosoriob May 9, 2026
ec395f1
test(e2e): assert hasConfiguration shape on softwareversions GET
mosoriob May 10, 2026
ebdb635
Merge branch 'main' into feat/bug-089-recursive-nested-writes
mosoriob May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .claude/skills/run-e2e-hasura/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
name: run-e2e-hasura
description: Use when running, writing, or debugging end-to-end integration tests for model-catalog-api against the local Hasura dev server at http://graphql.mint.local. Triggers on "run e2e", "test against hasura", "e2e fails", or working with files under model-catalog-api/src/__tests__/e2e/.
---

# Run E2E Tests Against Local Hasura

## What this is

End-to-end integration tests that exercise the full pipeline:

```
Vitest → buildApp() → fastify.inject() → routes → service.ts
→ Apollo Client → http://graphql.mint.local/v1/graphql → Postgres
```

In-process Fastify + real Apollo + real local Hasura. No mocks below the HTTP layer.

Suite is gated behind `npm run test:e2e`. Default `npm test` stays mock-only and fast.

## Prereqs

1. Local Hasura must be reachable at `http://graphql.mint.local/v1/graphql`. Quick check:

```bash
curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST http://graphql.mint.local/v1/graphql \
-H "X-Hasura-Admin-Secret: CHANGEME" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}'
```
Expected: `200`. If not: check `/etc/hosts` for `graphql.mint.local` and confirm any `kubectl port-forward` is running.

2. `npm install` is up to date inside `model-catalog-api/`.

## Run

```bash
cd model-catalog-api
npm run test:e2e # all e2e files
npm run test:e2e -- junction-e2e # one file
npm run test:e2e -- nested-write-e2e
```

## Environment variables

Defaults set by `src/__tests__/e2e/setup.ts`. Set in shell only to override.

| Var | Default | Purpose |
|-----|---------|---------|
| `HASURA_GRAPHQL_URL` | `http://graphql.mint.local/v1/graphql` | Local Hasura GraphQL endpoint. |
| `HASURA_ADMIN_SECRET` | `CHANGEME` | Admin secret. Must match local Hasura config. |
| `MINT_E2E_MODE` | `1` (forced) | Flips `getWriteClient()` to use admin-secret instead of Bearer. |
| `LOG_LEVEL` | `warn` | Reduces Fastify log noise during tests. |

## Writing new e2e tests

Use the helpers in `src/__tests__/e2e/helpers.ts`:

```ts
import { inject, trackId, uniqueId } from './helpers.js';

const id = uniqueId('software'); // collision-proof, prefixed with run id
trackId('softwares', id); // remember to delete in afterAll
const res = await inject(app, 'POST', '/v2.0.0/softwares', { id, label: ['x'], type: ['Software'] });
```

Rules:
- Always assert via a fresh GET, not the response body. Catches read-vs-write divergence (the bug-087 class).
- Always `trackId(resource, id)` for every entity created. Cleanup runs in `afterAll`.
- Never share IDs across tests — `uniqueId(kind)` is collision-proof per call.

## Hierarchy delete order

`cleanup(app)` deletes in REVERSE creation order. Track parents before children:

```
Software → SoftwareVersion → ModelConfiguration → ModelConfigurationSetup
```

If you create a Setup, also `trackId` the Config, Version, and Software it depends on (in that order, parents first).

## Don'ts

- No `--threads` and no parallel test files. The shared dev DB makes parallel writes step on each other. The vitest config (`vitest.e2e.config.ts`) enforces `singleFork`.
- No fixture seeds. Each test creates its own parents inline.
- Never run this suite against a shared production DB. The cleanup is best-effort, not guaranteed.

## Debugging recipes

| Symptom | Cause / Fix |
|---------|-------------|
| `Local Hasura unreachable at http://graphql.mint.local/v1/graphql` | `kubectl port-forward` not running, or `/etc/hosts` missing the entry, or Hasura pod down. |
| `401`/`403` on a write-path test | `MINT_E2E_MODE=1` not set in the shell when running outside `npm run test:e2e`. |
| GraphQL error `field … not found in type …` | Schema drift. Run `cd model-catalog-api && npm run codegen` against the current Hasura, then re-check assertions. |
| `cleanup: N orphan(s) remain` warning at end of run | Manual SQL cleanup needed. The warning prints the `RUN_ID` and the SQL templates. Run them in `psql` against the local DB. |
| Test hangs > 30s | Hasura is slow or hung. Check `kubectl logs` for the Hasura pod and `kubectl logs` for the Postgres pod. |
| New e2e test fails on a fresh Hasura but passes against the deployed cluster | Local Hasura migrations / metadata are out of sync. Apply migrations from `graphql_engine/`. |

## Manual orphan cleanup (if `RUN_ID` is known)

```sql
-- Replace RUN_ID with the value printed in the cleanup warning.
DELETE FROM modelcatalog_software_version_grid
WHERE software_version_id LIKE '%-RUN_ID-%' OR grid_id LIKE '%-RUN_ID-%';
DELETE FROM modelcatalog_software_version WHERE id LIKE '%-RUN_ID-%';
DELETE FROM modelcatalog_software WHERE id LIKE '%-RUN_ID-%';
DELETE FROM modelcatalog_grid WHERE id LIKE '%-RUN_ID-%';
DELETE FROM modelcatalog_configuration WHERE id LIKE '%-RUN_ID-%';
```

If the `RUN_ID` is unknown, all e2e rows have the prefix `e2e-` in the ID local part:

```sql
DELETE FROM modelcatalog_software WHERE id LIKE '%/software-e2e-%';
-- (and equivalent per table)
```
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Changelog

## v2.1.0 — 2026-05-09

### Breaking changes

- Relationship arrays no longer accept string-id form. Send objects: `hasInput: [{id: "..."}]`. Old form `hasInput: ["..."]` returns HTTP 400 `STRING_ID_DEPRECATED`. Migration: replace every `[string, ...]` array on relationship fields with `[{id: string}, ...]`.

### New features

- `POST` and `PUT` on every resource accept arbitrarily nested payloads (depth <= 8, total nodes <= 500, per-array length <= 200). Single atomic Hasura mutation per request. Replace-subtree semantics on `PUT`: payload IS the new state of every relationship at every depth.
- Dynamic `update_columns` per nested target row from supplied payload keys: id-only links without clobbering, id+scalars updates only those columns.

### Fixes

- bug-087: nested target on_conflict no longer clobbers existing rows when client sends only the id.
- bug-087 (PUT): junction FK column resolution from `resource-registry` (with optional `targetFkColumn` override). Hasura FK violations on writes now surface as 400 with `"id may target wrong resource type"` hint.
- bug-089: no parity gap between POST and PUT for nested writes.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Local Hasura E2E Tests

E2E integration tests against the local Hasura dev server are run with `npm run test:e2e`. For details on prereqs, env vars, writing new tests, debugging, and orphan cleanup, invoke the `run-e2e-hasura` skill.
2 changes: 1 addition & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ openapi: 3.0.1
info:
description: "This is the API of the Software Description Ontology at [https://w3id.org/okn/o/sdm](https://w3id.org/okn/o/sdm)"
title: Model Catalog
version: v2.0.0
version: v2.1.0
externalDocs:
description: Model Catalog
url: https://w3id.org/okn/o/sdm
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "model-catalog-api",
"version": "1.0.0",
"version": "2.1.0",
"description": "",
"type": "module",
"main": "dist/index.js",
Expand All @@ -9,7 +9,8 @@
"build": "tsc",
"start": "node dist/index.js",
"codegen": "graphql-codegen --config codegen.ts",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
},
"keywords": [],
"author": "",
Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';

export const RUN_ID = `e2e-${Date.now()}-${randomUUID().slice(0, 8)}`;

const ID_PREFIX = 'https://w3id.org/okn/i/mint';

export function uniqueId(kind: string): string {
return `${ID_PREFIX}/${kind}-${RUN_ID}-${randomUUID().slice(0, 6)}`;
}

export const E2E_HEADERS: Record<string, string> = {
Authorization: 'Bearer e2e-test',
'Content-Type': 'application/json',
};

interface Tracked {
resource: string;
id: string;
}

const created: Tracked[] = [];

export function trackId(resource: string, id: string): void {
created.push({ resource, id });
}

export interface InjectResult {
statusCode: number;
body: unknown;
rawPayload: string;
}

export async function inject(
app: FastifyInstance,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
payload?: unknown,
): Promise<InjectResult> {
const res = await app.inject({
method,
url: path,
headers: E2E_HEADERS,
payload: payload === undefined ? undefined : JSON.stringify(payload),
});
let body: unknown = undefined;
if (res.payload && res.payload.length > 0) {
try {
body = JSON.parse(res.payload);
} catch {
body = res.payload;
}
}
return { statusCode: res.statusCode, body, rawPayload: res.payload };
}

export async function cleanup(app: FastifyInstance): Promise<void> {
const orphans: Tracked[] = [];
// DELETE has no body — strip Content-Type so Fastify doesn't reject empty payload.
const { 'Content-Type': _ct, ...deleteHeaders } = E2E_HEADERS;
for (const t of [...created].reverse()) {
try {
const res = await app.inject({
method: 'DELETE',
url: `/v2.0.0/${t.resource}/${encodeURIComponent(t.id)}`,
headers: deleteHeaders,
});
if (res.statusCode >= 400 && res.statusCode !== 404) {
orphans.push(t);
// eslint-disable-next-line no-console
console.warn(
`cleanup: ${t.resource}/${t.id} delete returned ${res.statusCode}: ${res.payload}`,
);
}
} catch (err) {
orphans.push(t);
// eslint-disable-next-line no-console
console.warn(`cleanup: ${t.resource}/${t.id} threw`, err);
}
}
if (orphans.length > 0) {
// eslint-disable-next-line no-console
console.warn(
`cleanup: ${orphans.length} orphan(s) remain. RUN_ID=${RUN_ID}. Manual SQL:\n` +
` DELETE FROM modelcatalog_software_version WHERE id LIKE '%-${RUN_ID}-%';\n` +
` DELETE FROM modelcatalog_software WHERE id LIKE '%-${RUN_ID}-%';\n` +
` DELETE FROM modelcatalog_grid WHERE id LIKE '%-${RUN_ID}-%';`,
);
}
created.length = 0;
}
Loading
Loading