Skip to content

Commit eb8d009

Browse files
committed
feat: enhance TursoEnvironmentDatabaseAdapter with group support and add integration tests
1 parent 91cc129 commit eb8d009

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"typecheck": "tsc --noEmit",
1313
"test": "objectstack test",
1414
"test:e2e": "tsx test/e2e.test.ts",
15+
"test:provisioning": "tsx test/provisioning.test.ts",
1516
"clean": "rm -rf dist node_modules"
1617
},
1718
"dependencies": {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Integration test for EnvironmentProvisioningService + TursoEnvironmentDatabaseAdapter.
5+
*
6+
* Requires TURSO_ORG_NAME and TURSO_API_TOKEN — loaded from .env.local if present.
7+
*
8+
* Run: pnpm --filter @objectstack/server test:provisioning
9+
*/
10+
11+
import { readFileSync } from 'node:fs';
12+
import { resolve, dirname } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
// ---------------------------------------------------------------------------
16+
// Load .env.local
17+
// ---------------------------------------------------------------------------
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
const envLocalPath = resolve(__dirname, '../.env.local');
21+
try {
22+
const lines = readFileSync(envLocalPath, 'utf-8').split('\n');
23+
for (const line of lines) {
24+
const trimmed = line.trim();
25+
if (!trimmed || trimmed.startsWith('#')) continue;
26+
const eq = trimmed.indexOf('=');
27+
if (eq === -1) continue;
28+
const key = trimmed.slice(0, eq).trim();
29+
const raw = trimmed.slice(eq + 1).trim();
30+
const value = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw;
31+
if (!(key in process.env)) process.env[key] = value;
32+
}
33+
} catch {
34+
// .env.local absent — rely on environment variables already being set
35+
}
36+
37+
// ---------------------------------------------------------------------------
38+
// Assertions
39+
// ---------------------------------------------------------------------------
40+
41+
function assert(condition: boolean, message: string): asserts condition {
42+
if (!condition) throw new Error(`FAIL: ${message}`);
43+
}
44+
45+
function ok(label: string) {
46+
console.log(` ✓ ${label}`);
47+
}
48+
49+
// ---------------------------------------------------------------------------
50+
// Test: TursoEnvironmentDatabaseAdapter
51+
// ---------------------------------------------------------------------------
52+
53+
import {
54+
TursoEnvironmentDatabaseAdapter,
55+
createDefaultEnvironmentAdapters,
56+
EnvironmentProvisioningService,
57+
} from '@objectstack/service-tenant';
58+
import { TursoPlatformClient } from '@objectstack/service-tenant';
59+
60+
const orgName = process.env.TURSO_ORG_NAME;
61+
const apiToken = process.env.TURSO_API_TOKEN;
62+
63+
if (!orgName || !apiToken) {
64+
console.error('SKIP: TURSO_ORG_NAME and TURSO_API_TOKEN must be set.');
65+
process.exit(0);
66+
}
67+
68+
console.log('\n── Environment Provisioning Integration Test ──\n');
69+
console.log(` org: ${orgName}`);
70+
71+
const createdDbs: string[] = [];
72+
73+
async function cleanup(client: TursoPlatformClient) {
74+
for (const name of createdDbs) {
75+
try {
76+
await client.deleteDatabase(name);
77+
console.log(` 🗑 cleaned up: ${name}`);
78+
} catch (e) {
79+
console.warn(` ⚠ failed to delete ${name}:`, (e as Error).message);
80+
}
81+
}
82+
}
83+
84+
async function run() {
85+
const client = new TursoPlatformClient({ apiToken: apiToken!, organization: orgName! });
86+
const adapter = new TursoEnvironmentDatabaseAdapter({ apiToken: apiToken!, organization: orgName! });
87+
88+
// ── Test 1: createDefaultEnvironmentAdapters picks Turso when TURSO_ORG_NAME is set
89+
{
90+
const adapters = createDefaultEnvironmentAdapters();
91+
assert(adapters.length === 1, 'should return exactly one adapter');
92+
assert(adapters[0].driver === 'turso', `expected driver=turso, got ${adapters[0].driver}`);
93+
ok('createDefaultEnvironmentAdapters → TursoEnvironmentDatabaseAdapter');
94+
}
95+
96+
// ── Test 2: TursoEnvironmentDatabaseAdapter.createDatabase
97+
let databaseUrl = '';
98+
let plaintextSecret = '';
99+
let databaseName = '';
100+
{
101+
const envId = `test-${Date.now()}`;
102+
databaseName = `env-${envId}`;
103+
createdDbs.push(databaseName);
104+
105+
const result = await adapter.createDatabase({
106+
environmentId: envId,
107+
databaseName,
108+
region: 'us-east-1',
109+
storageLimitMb: 256,
110+
});
111+
112+
assert(result.databaseUrl.startsWith('libsql://'), `expected libsql:// URL, got: ${result.databaseUrl}`);
113+
assert(result.plaintextSecret.length > 0, 'expected non-empty JWT token');
114+
ok(`createDatabase → ${result.databaseUrl}`);
115+
116+
databaseUrl = result.databaseUrl;
117+
plaintextSecret = result.plaintextSecret;
118+
}
119+
120+
// ── Test 3: EnvironmentProvisioningService.provisionEnvironment end-to-end
121+
{
122+
const svc = new EnvironmentProvisioningService({
123+
adapters: [adapter],
124+
defaultDriver: 'turso',
125+
});
126+
127+
const dbName2 = `env-svc-${Date.now()}`;
128+
createdDbs.push(dbName2);
129+
130+
const result = await svc.provisionEnvironment({
131+
organizationId: 'org-integration-test',
132+
slug: 'test-env',
133+
envType: 'test',
134+
createdBy: 'integration-test',
135+
driver: 'turso',
136+
});
137+
138+
assert(result.environment.databaseUrl.startsWith('libsql://'), 'environment URL must be libsql://');
139+
assert(result.environment.databaseDriver === 'turso', 'driver must be turso');
140+
assert(result.credential.secretCiphertext.length > 0, 'credential must have ciphertext');
141+
ok(`provisionEnvironment → ${result.environment.databaseUrl}`);
142+
143+
// Track the actual db name for cleanup
144+
const hostname = result.environment.databaseUrl.replace('libsql://', '');
145+
const actualDbName = hostname.split('.')[0];
146+
if (!createdDbs.includes(actualDbName)) createdDbs.push(actualDbName);
147+
}
148+
149+
await cleanup(client);
150+
console.log('\n✅ All integration tests passed.\n');
151+
}
152+
153+
run().catch(async (err) => {
154+
const client = new TursoPlatformClient({ apiToken: apiToken!, organization: orgName! });
155+
await cleanup(client);
156+
console.error('\n❌', err.message);
157+
process.exit(1);
158+
});

packages/services/service-tenant/src/environment-provisioning.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { randomUUID } from 'node:crypto';
4-
import type { IDataDriver } from '@objectstack/spec';
4+
import type { Contracts } from '@objectstack/spec';
5+
type IDataDriver = Contracts.IDataDriver;
56
import type {
67
DatabaseCredential,
78
DatabaseDriver,
@@ -74,9 +75,11 @@ export class TursoEnvironmentDatabaseAdapter implements EnvironmentDatabaseAdapt
7475
readonly driver: DatabaseDriver = 'turso';
7576

7677
private readonly client: TursoPlatformClient;
78+
private readonly group: string;
7779

78-
constructor(config: { apiToken: string; organization: string; apiBaseUrl?: string }) {
80+
constructor(config: { apiToken: string; organization: string; group?: string; apiBaseUrl?: string }) {
7981
this.client = new TursoPlatformClient(config);
82+
this.group = config.group ?? 'default';
8083
}
8184

8285
async createDatabase(params: {
@@ -85,7 +88,7 @@ export class TursoEnvironmentDatabaseAdapter implements EnvironmentDatabaseAdapt
8588
region: string;
8689
storageLimitMb: number;
8790
}): Promise<{ databaseUrl: string; plaintextSecret: string }> {
88-
await this.client.createDatabase({ name: params.databaseName });
91+
await this.client.createDatabase({ name: params.databaseName, group: this.group });
8992
const { jwt } = await this.client.createDatabaseToken(params.databaseName, {
9093
authorization: 'full-access',
9194
});

0 commit comments

Comments
 (0)