Skip to content

Commit 78cca2e

Browse files
zeevdrclaude
andauthored
feat(tests): add integration suite against a real decree server
Adds a docker-compose fixture and Vitest integration project that runs the TypeScript SDK against a live decree server, covering all five scenarios from issue #59: snapshot, watch, reconnect, set with checksum, and set with abort. New files: - docker-compose.yml — postgres + redis + migrate + decree service; DECREE_SRC / TOOLS_IMAGE / SERVICE_IMAGE configurable for CI vs. local. - vitest.workspace.ts — workspace combining unit and integration projects. - integration/vitest.config.ts — Vitest 'integration' project config. - integration/setup.ts — global setup: waits for server, creates schema + tenant via generated stubs, seeds initial values, tears down on exit. - integration/suite.test.ts — five describe blocks covering all required scenarios. Updated: - vitest.config.ts — explicit include to isolate unit tests from integration/. - package.json — test:integration script; lint/format extended to integration/. - ci.yml — integration job builds images with GHCR cache, starts stack, runs suite, tears down unconditionally; added to the all-green gate. Closes #59 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4ad953f commit 78cca2e

8 files changed

Lines changed: 503 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# CI pipeline for the OpenDecree TypeScript SDK.
22
#
3-
# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples → check (alls-green gate)
3+
# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples, integration → check (alls-green gate)
44
# The check job aggregates all results for branch protection.
55
#
66
# The first job defines YAML anchors (&checkout, &setup-node-22, &install)
77
# on its setup steps; subsequent jobs alias them to avoid repetition.
88
# The test job uses its own setup-node step because the matrix node-version
99
# varies per run.
10+
#
11+
# The integration job checks out the decree server repo, builds the server
12+
# and tools images, then runs the Vitest integration suite against a
13+
# docker-compose stack (postgres + redis + decree).
1014

1115
name: CI
1216

@@ -97,10 +101,77 @@ jobs:
97101
- name: Typecheck examples
98102
run: cd examples && npx tsc --noEmit
99103

104+
integration:
105+
name: Integration
106+
runs-on: ubuntu-latest
107+
timeout-minutes: 20
108+
permissions:
109+
contents: read
110+
packages: read
111+
steps:
112+
- *checkout
113+
- *setup-node-22
114+
- *install
115+
116+
- name: Checkout decree server
117+
uses: actions/checkout@v6
118+
with:
119+
repository: opendecree/decree
120+
path: _decree
121+
persist-credentials: false
122+
123+
- name: Set up Docker Buildx
124+
uses: docker/setup-buildx-action@v4
125+
with:
126+
driver: docker-container
127+
128+
- name: Log in to ghcr.io
129+
uses: docker/login-action@v4
130+
with:
131+
registry: ghcr.io
132+
username: ${{ github.actor }}
133+
password: ${{ secrets.GITHUB_TOKEN }}
134+
135+
- name: Build tools image
136+
uses: docker/build-push-action@v7
137+
with:
138+
context: _decree
139+
file: _decree/build/Dockerfile.tools
140+
load: true
141+
tags: decree-tools
142+
cache-from: type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache
143+
cache-to: type=local,dest=/tmp/.buildx-cache-tools,mode=max
144+
145+
- name: Build server image
146+
uses: docker/build-push-action@v7
147+
with:
148+
context: _decree
149+
file: _decree/build/Dockerfile
150+
load: true
151+
tags: decree-server
152+
cache-from: type=registry,ref=ghcr.io/opendecree/decree:buildcache
153+
cache-to: type=local,dest=/tmp/.buildx-cache-server,mode=max
154+
155+
- name: Start decree stack
156+
run: docker compose up -d --wait service
157+
env:
158+
DECREE_SRC: ./_decree
159+
TOOLS_IMAGE: decree-tools
160+
SERVICE_IMAGE: decree-server
161+
162+
- name: Run integration tests
163+
run: npm run test:integration
164+
165+
- name: Tear down decree stack
166+
if: always()
167+
run: docker compose down -v
168+
env:
169+
DECREE_SRC: ./_decree
170+
100171
check:
101172
name: CI check
102173
if: always()
103-
needs: [lint, typecheck, test, examples, version]
174+
needs: [lint, typecheck, test, examples, version, integration]
104175
runs-on: ubuntu-latest
105176
steps:
106177
- uses: re-actors/alls-green@release/v1

docker-compose.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Integration test fixture — starts a minimal decree server stack.
2+
#
3+
# Usage (local):
4+
# Requires decree repo checked out at ../decree (decree-workspace layout).
5+
# cd decree-typescript
6+
# docker build -t decree-tools -f ../decree/build/Dockerfile.tools ../decree
7+
# docker build -t decree-server -f ../decree/build/Dockerfile ../decree
8+
# docker compose up -d --wait service
9+
# DECREE_INTEGRATION=1 npm run test:integration
10+
# docker compose down -v
11+
#
12+
# CI sets DECREE_SRC=./_decree, TOOLS_IMAGE, and SERVICE_IMAGE via env vars before running.
13+
# Note: DECREE_SRC must begin with ./ or / to be treated as a bind-mount path.
14+
15+
services:
16+
postgres:
17+
image: postgres:17
18+
environment:
19+
POSTGRES_DB: centralconfig
20+
POSTGRES_USER: centralconfig
21+
POSTGRES_PASSWORD: localdev
22+
ports:
23+
- "5432:5432"
24+
healthcheck:
25+
test: ["CMD-SHELL", "pg_isready -U centralconfig"]
26+
interval: 2s
27+
timeout: 5s
28+
retries: 10
29+
30+
redis:
31+
image: redis:7
32+
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
33+
ports:
34+
- "6379:6379"
35+
healthcheck:
36+
test: ["CMD", "redis-cli", "ping"]
37+
interval: 2s
38+
timeout: 5s
39+
retries: 10
40+
41+
migrate:
42+
image: ${TOOLS_IMAGE:-decree-tools}
43+
depends_on:
44+
postgres:
45+
condition: service_healthy
46+
volumes:
47+
- ${DECREE_SRC:-../decree}/db/migrations:/migrations:ro
48+
command:
49+
[
50+
"goose",
51+
"-dir",
52+
"/migrations",
53+
"postgres",
54+
"postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable",
55+
"up",
56+
]
57+
58+
service:
59+
image: ${SERVICE_IMAGE:-decree-server}
60+
build:
61+
context: ${DECREE_SRC:-../decree}
62+
dockerfile: build/Dockerfile
63+
depends_on:
64+
migrate:
65+
condition: service_completed_successfully
66+
redis:
67+
condition: service_healthy
68+
environment:
69+
GRPC_PORT: "9090"
70+
DB_WRITE_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable"
71+
DB_READ_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable"
72+
REDIS_URL: "redis://redis:6379"
73+
ENABLE_SERVICES: "schema,config"
74+
HTTP_PORT: "8080"
75+
INSECURE_LISTEN: "1"
76+
ENABLE_REFLECTION: "1"
77+
ports:
78+
- "9090:9090"
79+
- "8080:8080"
80+
81+
volumes:
82+
pgdata:

integration/setup.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Global setup for integration tests.
3+
*
4+
* Creates a schema + tenant before all tests, sets initial values, then
5+
* tears everything down afterward. The tenantId and serverAddr are
6+
* provided to tests via Vitest's inject() mechanism.
7+
*/
8+
9+
import { credentials, Metadata, type ServiceError } from "@grpc/grpc-js";
10+
import type { GlobalSetupContext } from "vitest/node";
11+
import { ConfigClient } from "../src/client.js";
12+
import { ConfigServiceClient } from "../src/generated/centralconfig/v1/config_service.js";
13+
import { SchemaServiceClient } from "../src/generated/centralconfig/v1/schema_service.js";
14+
import { FieldType } from "../src/generated/centralconfig/v1/types.js";
15+
16+
declare module "vitest" {
17+
interface ProvidedContext {
18+
tenantId: string;
19+
schemaId: string;
20+
serverAddr: string;
21+
}
22+
}
23+
24+
function promisify<Req, Res>(
25+
fn: (req: Req, meta: Metadata, cb: (err: ServiceError | null, res: Res) => void) => void,
26+
req: Req,
27+
meta: Metadata,
28+
): Promise<Res> {
29+
return new Promise((resolve, reject) => {
30+
fn(req, meta, (err, res) => {
31+
if (err) reject(err);
32+
else resolve(res as Res);
33+
});
34+
});
35+
}
36+
37+
export async function setup({ provide }: GlobalSetupContext) {
38+
const serverAddr = process.env.DECREE_SERVER_ADDR ?? "localhost:9090";
39+
const creds = credentials.createInsecure();
40+
41+
const meta = new Metadata();
42+
meta.set("x-subject", "integration-test");
43+
meta.set("x-role", "superadmin");
44+
45+
const schemaClient = new SchemaServiceClient(serverAddr, creds);
46+
47+
// Wait up to 30s for the server to accept gRPC connections.
48+
await new Promise<void>((resolve, reject) => {
49+
schemaClient.waitForReady(new Date(Date.now() + 30_000), (err) => {
50+
if (err) reject(new Error(`server not ready at ${serverAddr}: ${err.message}`));
51+
else resolve();
52+
});
53+
});
54+
55+
const schemaName = `ts-sdk-int-${Date.now()}`;
56+
const { schema } = await promisify(
57+
schemaClient.createSchema.bind(schemaClient),
58+
{
59+
name: schemaName,
60+
description: "TypeScript SDK integration test schema",
61+
fields: [
62+
{
63+
path: "app.fee",
64+
type: FieldType.FIELD_TYPE_STRING,
65+
constraints: undefined,
66+
nullable: true,
67+
deprecated: false,
68+
examples: {},
69+
tags: [],
70+
},
71+
{
72+
path: "app.count",
73+
type: FieldType.FIELD_TYPE_INT,
74+
constraints: undefined,
75+
nullable: false,
76+
deprecated: false,
77+
examples: {},
78+
tags: [],
79+
},
80+
{
81+
path: "app.enabled",
82+
type: FieldType.FIELD_TYPE_BOOL,
83+
constraints: undefined,
84+
nullable: false,
85+
deprecated: false,
86+
examples: {},
87+
tags: [],
88+
},
89+
],
90+
},
91+
meta,
92+
);
93+
if (!schema) throw new Error("createSchema returned no schema");
94+
const schemaId = schema.id;
95+
96+
await promisify(
97+
schemaClient.publishSchema.bind(schemaClient),
98+
{ id: schemaId, version: 1 },
99+
meta,
100+
);
101+
102+
const tenantName = `ts-sdk-tenant-${Date.now()}`;
103+
const { tenant } = await promisify(
104+
schemaClient.createTenant.bind(schemaClient),
105+
{ name: tenantName, schemaId, schemaVersion: 1 },
106+
meta,
107+
);
108+
if (!tenant) throw new Error("createTenant returned no tenant");
109+
const tenantId = tenant.id;
110+
111+
const configClient = new ConfigClient(serverAddr, {
112+
insecure: true,
113+
subject: "integration-test",
114+
role: "superadmin",
115+
retry: false,
116+
});
117+
const rawConfig = new ConfigServiceClient(serverAddr, creds);
118+
await configClient.set(tenantId, "app.fee", "0.5%");
119+
await promisify(
120+
rawConfig.setField.bind(rawConfig),
121+
{ tenantId, fieldPath: "app.count", value: { integerValue: 42 } },
122+
meta,
123+
);
124+
await configClient.setBool(tenantId, "app.enabled", true);
125+
configClient.close();
126+
rawConfig.close();
127+
128+
provide("tenantId", tenantId);
129+
provide("schemaId", schemaId);
130+
provide("serverAddr", serverAddr);
131+
132+
return async () => {
133+
await promisify(schemaClient.deleteTenant.bind(schemaClient), { id: tenantId }, meta);
134+
await promisify(schemaClient.deleteSchema.bind(schemaClient), { id: schemaId }, meta);
135+
schemaClient.close();
136+
};
137+
}

0 commit comments

Comments
 (0)