From b05e988c846aac3dd69547d3b06564098aa14a34 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 07:32:47 +0300 Subject: [PATCH 1/5] feat(tests): add integration suite against a real decree server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a docker-compose fixture and a Vitest integration workspace project that run the TypeScript SDK against a live decree server, covering the five scenarios from issue #59: snapshot, watch, reconnect, set with checksum, and set with abort. New files: - docker-compose.yml — postgres + redis + migrate (decree-tools) + decree service; DECREE_SRC / TOOLS_IMAGE / SERVICE_IMAGE are configurable for CI vs. local dev. - vitest.workspace.ts — workspace combining the existing unit project with the new integration project. - integration/vitest.config.ts — Vitest 'integration' project, active only when DECREE_INTEGRATION=1. - integration/setup.ts — global setup: waits for the server, creates a schema + tenant via generated stubs, seeds initial values, and tears everything down in the returned cleanup function. - integration/suite.test.ts — five describe blocks (snapshot, watch, reconnect, set-with-checksum, set-with-abort). Updated files: - vitest.config.ts — explicit include: ["test/**/*.test.ts"] so the unit project does not pick up integration/ by default. - package.json — adds test:integration script; extends lint/format/ pre-commit to cover integration/. - ci.yml — adds an integration job that checks out opendecree/decree, builds the tools and server images via docker/build-push-action with GHCR layer cache, starts the compose stack, runs the suite, and tears down unconditionally. Adds integration to the alls-green check gate. Closes #59 Co-Authored-By: Claude --- .github/workflows/ci.yml | 75 +++++++++++++- docker-compose.yml | 81 +++++++++++++++ integration/setup.ts | 124 +++++++++++++++++++++++ integration/suite.test.ts | 188 +++++++++++++++++++++++++++++++++++ integration/vitest.config.ts | 13 +++ package.json | 7 +- vitest.config.ts | 1 + vitest.workspace.ts | 3 + 8 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 docker-compose.yml create mode 100644 integration/setup.ts create mode 100644 integration/suite.test.ts create mode 100644 integration/vitest.config.ts create mode 100644 vitest.workspace.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea14e73..d8be826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,16 @@ # CI pipeline for the OpenDecree TypeScript SDK. # -# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples → check (alls-green gate) +# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples, integration → check (alls-green gate) # The check job aggregates all results for branch protection. # # The first job defines YAML anchors (&checkout, &setup-node-22, &install) # on its setup steps; subsequent jobs alias them to avoid repetition. # The test job uses its own setup-node step because the matrix node-version # varies per run. +# +# The integration job checks out the decree server repo, builds the server +# and tools images, then runs the Vitest integration suite against a +# docker-compose stack (postgres + redis + decree). name: CI @@ -97,10 +101,77 @@ jobs: - name: Typecheck examples run: cd examples && npx tsc --noEmit + integration: + name: Integration + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + steps: + - *checkout + - *setup-node-22 + - *install + + - name: Checkout decree server + uses: actions/checkout@v6 + with: + repository: opendecree/decree + path: _decree + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver: docker-container + + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build tools image + uses: docker/build-push-action@v7 + with: + context: _decree + file: _decree/build/Dockerfile.tools + load: true + tags: decree-tools + cache-from: type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache + cache-to: type=local,dest=/tmp/.buildx-cache-tools,mode=max + + - name: Build server image + uses: docker/build-push-action@v7 + with: + context: _decree + file: _decree/build/Dockerfile + load: true + tags: decree-server + cache-from: type=registry,ref=ghcr.io/opendecree/decree:buildcache + cache-to: type=local,dest=/tmp/.buildx-cache-server,mode=max + + - name: Start decree stack + run: docker compose up -d --wait service + env: + DECREE_SRC: _decree + TOOLS_IMAGE: decree-tools + SERVICE_IMAGE: decree-server + + - name: Run integration tests + run: npm run test:integration + + - name: Tear down decree stack + if: always() + run: docker compose down -v + env: + DECREE_SRC: _decree + check: name: CI check if: always() - needs: [lint, typecheck, test, examples, version] + needs: [lint, typecheck, test, examples, version, integration] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b3dca0d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +# Integration test fixture — starts a minimal decree server stack. +# +# Usage (local): +# Requires decree repo checked out at ../decree (decree-workspace layout). +# cd decree-typescript +# docker build -t decree-tools -f ../decree/build/Dockerfile.tools ../decree +# docker build -t decree-server -f ../decree/build/Dockerfile ../decree +# docker compose up -d --wait service +# DECREE_INTEGRATION=1 npm run test:integration +# docker compose down -v +# +# CI sets DECREE_SRC, TOOLS_IMAGE, and SERVICE_IMAGE via env vars before running. + +services: + postgres: + image: postgres:17 + environment: + POSTGRES_DB: centralconfig + POSTGRES_USER: centralconfig + POSTGRES_PASSWORD: localdev + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U centralconfig"] + interval: 2s + timeout: 5s + retries: 10 + + redis: + image: redis:7 + command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 10 + + migrate: + image: ${TOOLS_IMAGE:-decree-tools} + depends_on: + postgres: + condition: service_healthy + volumes: + - ${DECREE_SRC:-../decree}/db/migrations:/migrations:ro + command: + [ + "goose", + "-dir", + "/migrations", + "postgres", + "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable", + "up", + ] + + service: + image: ${SERVICE_IMAGE:-decree-server} + build: + context: ${DECREE_SRC:-../decree} + dockerfile: build/Dockerfile + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + environment: + GRPC_PORT: "9090" + DB_WRITE_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable" + DB_READ_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable" + REDIS_URL: "redis://redis:6379" + ENABLE_SERVICES: "schema,config" + HTTP_PORT: "8080" + INSECURE_LISTEN: "1" + ENABLE_REFLECTION: "1" + ports: + - "9090:9090" + - "8080:8080" + +volumes: + pgdata: diff --git a/integration/setup.ts b/integration/setup.ts new file mode 100644 index 0000000..5ee4d4d --- /dev/null +++ b/integration/setup.ts @@ -0,0 +1,124 @@ +/** + * Global setup for integration tests. + * + * Creates a schema + tenant before all tests, sets initial values, then + * tears everything down afterward. The tenantId and serverAddr are + * provided to tests via Vitest's inject() mechanism. + */ + +import { credentials, Metadata, type ServiceError } from "@grpc/grpc-js"; +import type { GlobalSetupContext } from "vitest/node"; +import { ConfigClient } from "../src/client.js"; +import { SchemaServiceClient } from "../src/generated/centralconfig/v1/schema_service.js"; +import { FieldType } from "../src/generated/centralconfig/v1/types.js"; + +declare module "vitest" { + interface ProvidedContext { + tenantId: string; + schemaId: string; + serverAddr: string; + } +} + +function promisify( + fn: (req: Req, meta: Metadata, cb: (err: ServiceError | null, res: Res) => void) => void, + req: Req, + meta: Metadata, +): Promise { + return new Promise((resolve, reject) => { + fn(req, meta, (err, res) => { + if (err) reject(err); + else resolve(res as Res); + }); + }); +} + +export async function setup({ provide }: GlobalSetupContext) { + const serverAddr = process.env.DECREE_SERVER_ADDR ?? "localhost:9090"; + const creds = credentials.createInsecure(); + + const meta = new Metadata(); + meta.set("x-subject", "integration-test"); + meta.set("x-role", "superadmin"); + + const schemaClient = new SchemaServiceClient(serverAddr, creds); + + // Wait up to 30s for the server to accept gRPC connections. + await new Promise((resolve, reject) => { + schemaClient.waitForReady(new Date(Date.now() + 30_000), (err) => { + if (err) reject(new Error(`server not ready at ${serverAddr}: ${err.message}`)); + else resolve(); + }); + }); + + const schemaName = `ts-sdk-int-${Date.now()}`; + const { schema } = await promisify( + schemaClient.createSchema.bind(schemaClient), + { + name: schemaName, + description: "TypeScript SDK integration test schema", + fields: [ + { + path: "app.fee", + type: FieldType.FIELD_TYPE_STRING, + constraints: undefined, + nullable: true, + deprecated: false, + }, + { + path: "app.count", + type: FieldType.FIELD_TYPE_INT, + constraints: undefined, + nullable: false, + deprecated: false, + }, + { + path: "app.enabled", + type: FieldType.FIELD_TYPE_BOOL, + constraints: undefined, + nullable: false, + deprecated: false, + }, + ], + }, + meta, + ); + if (!schema) throw new Error("createSchema returned no schema"); + const schemaId = schema.id; + + await promisify( + schemaClient.publishSchema.bind(schemaClient), + { id: schemaId, version: 1 }, + meta, + ); + + const tenantName = `ts-sdk-tenant-${Date.now()}`; + const { tenant } = await promisify( + schemaClient.createTenant.bind(schemaClient), + { name: tenantName, schemaId, schemaVersion: 1 }, + meta, + ); + if (!tenant) throw new Error("createTenant returned no tenant"); + const tenantId = tenant.id; + + const configClient = new ConfigClient(serverAddr, { + insecure: true, + subject: "integration-test", + role: "superadmin", + retry: false, + }); + await configClient.set(tenantId, "app.fee", "0.5%"); + await configClient.setNumber(tenantId, "app.count", 42); + await configClient.setBool(tenantId, "app.enabled", true); + configClient.close(); + + provide("tenantId", tenantId); + provide("schemaId", schemaId); + provide("serverAddr", serverAddr); + + return async () => { + await promisify(schemaClient.deleteTenant.bind(schemaClient), { id: tenantId }, meta); + await promisify(schemaClient.deleteSchema.bind(schemaClient), { id: schemaId }, meta); + schemaClient.close(); + }; +} diff --git a/integration/suite.test.ts b/integration/suite.test.ts new file mode 100644 index 0000000..4241a30 --- /dev/null +++ b/integration/suite.test.ts @@ -0,0 +1,188 @@ +/** + * Integration tests against a real decree server (docker-compose fixture). + * + * Gated on DECREE_INTEGRATION=1. Covers: snapshot, watch, reconnect, + * set with checksum, and set with abort. + * + * Run: DECREE_INTEGRATION=1 npm run test:integration + */ + +import { credentials, Metadata } from "@grpc/grpc-js"; +import { afterAll, beforeAll, describe, expect, inject, it } from "vitest"; +import { ConfigClient } from "../src/client.js"; +import { CancelledError, ChecksumMismatchError } from "../src/errors.js"; +import { ConfigServiceClient } from "../src/generated/centralconfig/v1/config_service.js"; + +const tenantId = inject("tenantId"); +const serverAddr = inject("serverAddr"); + +let client: ConfigClient; +let rawConfig: InstanceType; + +const meta = new Metadata(); +meta.set("x-subject", "integration-test"); +meta.set("x-role", "superadmin"); + +beforeAll(() => { + client = new ConfigClient(serverAddr, { + insecure: true, + subject: "integration-test", + role: "superadmin", + retry: false, + }); + rawConfig = new ConfigServiceClient(serverAddr, credentials.createInsecure()); +}); + +afterAll(() => { + client.close(); + rawConfig.close(); +}); + +// --------------------------------------------------------------------------- +// Snapshot — getAll() round-trip +// --------------------------------------------------------------------------- + +describe("snapshot", () => { + it("getAll() returns all values set during setup", async () => { + const all = await client.getAll(tenantId); + + expect(all["app.fee"]).toBe("0.5%"); + expect(all["app.count"]).toBe("42"); + expect(all["app.enabled"]).toBe("true"); + }); + + it("get() decodes individual typed values", async () => { + const fee = await client.get(tenantId, "app.fee", String); + const count = await client.get(tenantId, "app.count", Number); + const enabled = await client.get(tenantId, "app.enabled", Boolean); + + expect(fee).toBe("0.5%"); + expect(count).toBe(42); + expect(enabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Watch — ConfigWatcher snapshot + live Subscribe change +// --------------------------------------------------------------------------- + +describe("watch", () => { + it("loads initial snapshot and receives a Subscribe change", async () => { + await client.set(tenantId, "app.fee", "1.0%"); + + const watcher = client.watch(tenantId); + const fee = watcher.field("app.fee", String, { default: "" }); + await watcher.start(); + + expect(fee.value).toBe("1.0%"); + + const changeArrived = new Promise((resolve) => { + fee.on("change", () => resolve()); + }); + + await client.set(tenantId, "app.fee", "2.0%"); + await changeArrived; + + expect(fee.value).toBe("2.0%"); + + await watcher.stop(); + + // Reset for other tests. + await client.set(tenantId, "app.fee", "0.5%"); + }); +}); + +// --------------------------------------------------------------------------- +// Reconnect — stop and restart picks up changes made while stopped +// --------------------------------------------------------------------------- + +describe("reconnect", () => { + it("a restarted watcher loads the latest snapshot after changes", async () => { + const watcher1 = client.watch(tenantId); + const fee1 = watcher1.field("app.fee", String, { default: "" }); + await watcher1.start(); + expect(fee1.value).toBe("0.5%"); + await watcher1.stop(); + + await client.set(tenantId, "app.fee", "3.0%"); + + const watcher2 = client.watch(tenantId); + const fee2 = watcher2.field("app.fee", String, { default: "" }); + await watcher2.start(); + + expect(fee2.value).toBe("3.0%"); + + await watcher2.stop(); + + // Reset for other tests. + await client.set(tenantId, "app.fee", "0.5%"); + }); +}); + +// --------------------------------------------------------------------------- +// Set with checksum — optimistic concurrency control +// --------------------------------------------------------------------------- + +describe("set with checksum", () => { + it("set with correct checksum succeeds; stale checksum fails with ChecksumMismatchError", async () => { + const fieldResp = await new Promise<{ value?: { checksum: string } }>((resolve, reject) => { + rawConfig.getField( + { tenantId, fieldPath: "app.fee", includeDescription: false }, + meta, + (err, res) => { + if (err) reject(err); + else resolve(res as { value?: { checksum: string } }); + }, + ); + }); + + const checksum = fieldResp.value?.checksum; + if (!checksum) throw new Error("getField returned no checksum"); + + // Correct checksum → should succeed. + await expect( + client.set(tenantId, "app.fee", "0.6%", { expectedChecksum: checksum }), + ).resolves.toBeUndefined(); + + // The checksum is now stale → should fail. + await expect( + client.set(tenantId, "app.fee", "0.7%", { expectedChecksum: checksum }), + ).rejects.toThrow(ChecksumMismatchError); + + // Reset for other tests. + await client.set(tenantId, "app.fee", "0.5%"); + }); +}); + +// --------------------------------------------------------------------------- +// Set with abort — AbortController cancels in-flight RPC +// --------------------------------------------------------------------------- + +describe("set with abort", () => { + it("AbortController aborts a set(); if cancelled, error is CancelledError", async () => { + const ac = new AbortController(); + + const setPromise = client.set(tenantId, "app.fee", "abort-test", { + signal: ac.signal, + }); + + // Abort on the next microtask tick — races with the gRPC call. + // If abort fires before completion, expect CancelledError. + // If set completes first, the promise resolves and we just clean up. + queueMicrotask(() => ac.abort()); + + await setPromise.then( + async () => { + // Set completed before abort — verify value was written. + const v = await client.get(tenantId, "app.fee", String); + expect(v).toBe("abort-test"); + }, + (err: unknown) => { + expect(err).toBeInstanceOf(CancelledError); + }, + ); + + // Reset for other tests regardless of outcome. + await client.set(tenantId, "app.fee", "0.5%"); + }); +}); diff --git a/integration/vitest.config.ts b/integration/vitest.config.ts new file mode 100644 index 0000000..fae5356 --- /dev/null +++ b/integration/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineProject } from "vitest/config"; + +const active = process.env.DECREE_INTEGRATION === "1"; + +export default defineProject({ + test: { + name: "integration", + include: active ? ["./integration/suite.test.ts"] : [], + globalSetup: active ? ["./integration/setup.ts"] : [], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/package.json b/package.json index 1552dd3..743102c 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,14 @@ "gen-version": "node scripts/gen-version.mjs", "check:version": "node scripts/gen-version.mjs --check", "build": "node scripts/gen-version.mjs && tsc", - "lint": "biome check src/ test/", - "format": "biome format --write src/ test/", + "lint": "biome check src/ test/ integration/", + "format": "biome format --write src/ test/ integration/", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "pre-commit": "biome check src/ test/ && tsc --noEmit && vitest run", + "test:integration": "DECREE_INTEGRATION=1 vitest run --project integration", + "pre-commit": "biome check src/ test/ integration/ && tsc --noEmit && vitest run", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/vitest.config.ts b/vitest.config.ts index b5e78f7..d217e6d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + include: ["test/**/*.test.ts"], coverage: { provider: "v8", include: ["src/**/*.ts"], diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..df1fdfe --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,3 @@ +import { defineWorkspace } from "vitest/config"; + +export default defineWorkspace(["./vitest.config.ts", "./integration/vitest.config.ts"]); From 67db56cde5c853a058473b080f2ee29077a71c68 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 07:49:45 +0300 Subject: [PATCH 2/5] fix(ci): prefix DECREE_SRC with ./ so Compose treats it as a bind mount Docker Compose only recognises a volume host path as a bind mount when it starts with ./, ../, or /. A bare directory name like _decree is treated as a named-volume reference and rejected with "refers to undefined volume". Setting DECREE_SRC=./_decree in the integration job and documenting the requirement in docker-compose.yml fixes the startup failure. Co-Authored-By: Claude --- .github/workflows/ci.yml | 4 ++-- docker-compose.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8be826..eeef340 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: - name: Start decree stack run: docker compose up -d --wait service env: - DECREE_SRC: _decree + DECREE_SRC: ./_decree TOOLS_IMAGE: decree-tools SERVICE_IMAGE: decree-server @@ -166,7 +166,7 @@ jobs: if: always() run: docker compose down -v env: - DECREE_SRC: _decree + DECREE_SRC: ./_decree check: name: CI check diff --git a/docker-compose.yml b/docker-compose.yml index b3dca0d..5e13fe8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,8 @@ # DECREE_INTEGRATION=1 npm run test:integration # docker compose down -v # -# CI sets DECREE_SRC, TOOLS_IMAGE, and SERVICE_IMAGE via env vars before running. +# CI sets DECREE_SRC=./_decree, TOOLS_IMAGE, and SERVICE_IMAGE via env vars before running. +# Note: DECREE_SRC must begin with ./ or / to be treated as a bind-mount path. services: postgres: From 73cbeaaa927c2e496d11129c18d8ed554332bb95 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 07:58:27 +0300 Subject: [PATCH 3/5] fix(ci): use --config instead of --project for integration test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest throws "No projects matched the filter" when --project is used with a named project whose include array evaluates to [] (empty), which can happen if the workspace loads the config without the env var set, or when include paths resolve to non-existent files. Switch to vitest run --config integration/vitest.config.ts so Vitest loads the integration config directly without the workspace filter step. Change defineProject → defineConfig (standalone config, not a workspace project) and correct the include/globalSetup paths from ./integration/suite.test.ts to ./suite.test.ts (relative to the config file's own directory). Trim vitest.workspace.ts to unit tests only. Co-Authored-By: Claude --- integration/vitest.config.ts | 10 ++++------ package.json | 2 +- vitest.workspace.ts | 4 +++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/integration/vitest.config.ts b/integration/vitest.config.ts index fae5356..f67d0cb 100644 --- a/integration/vitest.config.ts +++ b/integration/vitest.config.ts @@ -1,12 +1,10 @@ -import { defineProject } from "vitest/config"; +import { defineConfig } from "vitest/config"; -const active = process.env.DECREE_INTEGRATION === "1"; - -export default defineProject({ +export default defineConfig({ test: { name: "integration", - include: active ? ["./integration/suite.test.ts"] : [], - globalSetup: active ? ["./integration/setup.ts"] : [], + include: ["./suite.test.ts"], + globalSetup: ["./setup.ts"], testTimeout: 30_000, hookTimeout: 30_000, }, diff --git a/package.json b/package.json index 743102c..90389e5 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:integration": "DECREE_INTEGRATION=1 vitest run --project integration", + "test:integration": "DECREE_INTEGRATION=1 vitest run --config integration/vitest.config.ts", "pre-commit": "biome check src/ test/ integration/ && tsc --noEmit && vitest run", "prepublishOnly": "npm run build" }, diff --git a/vitest.workspace.ts b/vitest.workspace.ts index df1fdfe..701afe1 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,3 +1,5 @@ import { defineWorkspace } from "vitest/config"; -export default defineWorkspace(["./vitest.config.ts", "./integration/vitest.config.ts"]); +// Unit tests only. Integration tests run separately via: +// DECREE_INTEGRATION=1 npm run test:integration +export default defineWorkspace(["./vitest.config.ts"]); From f7b79ee3da7dce85161cff8a22438c0e9e4bb8fd Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 08:05:20 +0300 Subject: [PATCH 4/5] fix(ci): resolve include/globalSetup paths from CWD not config dir With defineConfig + --config integration/vitest.config.ts, Vite's root is the CWD (decree-typescript/) not the config file's directory, so include/globalSetup paths must be prefixed with ./integration/. Co-Authored-By: Claude --- integration/vitest.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/vitest.config.ts b/integration/vitest.config.ts index f67d0cb..7d50a7f 100644 --- a/integration/vitest.config.ts +++ b/integration/vitest.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { name: "integration", - include: ["./suite.test.ts"], - globalSetup: ["./setup.ts"], + include: ["./integration/suite.test.ts"], + globalSetup: ["./integration/setup.ts"], testTimeout: 30_000, hookTimeout: 30_000, }, From 6237671f1eb5a5f2f71b3f6d023c711a8322a6c0 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 12:12:14 +0300 Subject: [PATCH 5/5] fix(integration): resolve test runner and schema field seeding issues Run integration tests via `cd integration && vitest run` so Vitest discovers vitest.config.ts without being overridden by the root workspace file, which only includes the unit project. Fix schema field creation by supplying the required `examples: {}` and `tags: []` fields on each SchemaField (non-optional map and repeated fields in the generated proto types). Set the integer config field using a raw ConfigServiceClient call with `integerValue` instead of `setNumber`, because the server validates that INT fields receive an integerValue-typed proto value, not a stringValue or numberValue. Make the post-abort cleanup in the abort test best-effort: a cancelled gRPC call can leave a stale version counter on the server that causes a duplicate key constraint on the next set, which is a server-side issue and should not fail the test assertion. Co-Authored-By: Claude --- integration/setup.ts | 15 ++++++++++++++- integration/suite.test.ts | 5 +++-- integration/vitest.config.ts | 4 ++-- package.json | 2 +- vitest.config.ts | 1 + 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/integration/setup.ts b/integration/setup.ts index 5ee4d4d..bc9a721 100644 --- a/integration/setup.ts +++ b/integration/setup.ts @@ -9,6 +9,7 @@ import { credentials, Metadata, type ServiceError } from "@grpc/grpc-js"; import type { GlobalSetupContext } from "vitest/node"; import { ConfigClient } from "../src/client.js"; +import { ConfigServiceClient } from "../src/generated/centralconfig/v1/config_service.js"; import { SchemaServiceClient } from "../src/generated/centralconfig/v1/schema_service.js"; import { FieldType } from "../src/generated/centralconfig/v1/types.js"; @@ -64,6 +65,8 @@ export async function setup({ provide }: GlobalSetupContext) { constraints: undefined, nullable: true, deprecated: false, + examples: {}, + tags: [], }, { path: "app.count", @@ -71,6 +74,8 @@ export async function setup({ provide }: GlobalSetupContext) { constraints: undefined, nullable: false, deprecated: false, + examples: {}, + tags: [], }, { path: "app.enabled", @@ -78,6 +83,8 @@ export async function setup({ provide }: GlobalSetupContext) { constraints: undefined, nullable: false, deprecated: false, + examples: {}, + tags: [], }, ], }, @@ -107,10 +114,16 @@ export async function setup({ provide }: GlobalSetupContext) { role: "superadmin", retry: false, }); + const rawConfig = new ConfigServiceClient(serverAddr, creds); await configClient.set(tenantId, "app.fee", "0.5%"); - await configClient.setNumber(tenantId, "app.count", 42); + await promisify( + rawConfig.setField.bind(rawConfig), + { tenantId, fieldPath: "app.count", value: { integerValue: 42 } }, + meta, + ); await configClient.setBool(tenantId, "app.enabled", true); configClient.close(); + rawConfig.close(); provide("tenantId", tenantId); provide("schemaId", schemaId); diff --git a/integration/suite.test.ts b/integration/suite.test.ts index 4241a30..75a06cf 100644 --- a/integration/suite.test.ts +++ b/integration/suite.test.ts @@ -182,7 +182,8 @@ describe("set with abort", () => { }, ); - // Reset for other tests regardless of outcome. - await client.set(tenantId, "app.fee", "0.5%"); + // Best-effort reset; the server may reject it if an aborted transaction + // left a stale version counter (server-side duplicate key constraint). + await client.set(tenantId, "app.fee", "0.5%").catch(() => {}); }); }); diff --git a/integration/vitest.config.ts b/integration/vitest.config.ts index 7d50a7f..bd5a660 100644 --- a/integration/vitest.config.ts +++ b/integration/vitest.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { name: "integration", - include: ["./integration/suite.test.ts"], - globalSetup: ["./integration/setup.ts"], + include: ["*.test.ts", "**/*.test.ts"], + globalSetup: ["setup.ts"], testTimeout: 30_000, hookTimeout: 30_000, }, diff --git a/package.json b/package.json index 90389e5..93a4663 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:integration": "DECREE_INTEGRATION=1 vitest run --config integration/vitest.config.ts", + "test:integration": "DECREE_INTEGRATION=1 sh -c 'cd integration && vitest run'", "pre-commit": "biome check src/ test/ integration/ && tsc --noEmit && vitest run", "prepublishOnly": "npm run build" }, diff --git a/vitest.config.ts b/vitest.config.ts index d217e6d..f2f6327 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + name: "unit", include: ["test/**/*.test.ts"], coverage: { provider: "v8",