diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea14e73..eeef340 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..5e13fe8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +# 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=./_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: + 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..bc9a721 --- /dev/null +++ b/integration/setup.ts @@ -0,0 +1,137 @@ +/** + * 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 { 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"; + +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, + examples: {}, + tags: [], + }, + { + path: "app.count", + type: FieldType.FIELD_TYPE_INT, + constraints: undefined, + nullable: false, + deprecated: false, + examples: {}, + tags: [], + }, + { + path: "app.enabled", + type: FieldType.FIELD_TYPE_BOOL, + constraints: undefined, + nullable: false, + deprecated: false, + examples: {}, + tags: [], + }, + ], + }, + 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, + }); + const rawConfig = new ConfigServiceClient(serverAddr, creds); + await configClient.set(tenantId, "app.fee", "0.5%"); + 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); + 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..75a06cf --- /dev/null +++ b/integration/suite.test.ts @@ -0,0 +1,189 @@ +/** + * 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); + }, + ); + + // 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 new file mode 100644 index 0000000..bd5a660 --- /dev/null +++ b/integration/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "integration", + include: ["*.test.ts", "**/*.test.ts"], + globalSetup: ["setup.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/package.json b/package.json index 1552dd3..93a4663 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 sh -c 'cd integration && vitest run'", + "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..f2f6327 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + name: "unit", + 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..701afe1 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,5 @@ +import { defineWorkspace } from "vitest/config"; + +// Unit tests only. Integration tests run separately via: +// DECREE_INTEGRATION=1 npm run test:integration +export default defineWorkspace(["./vitest.config.ts"]);