feat: Sprint 1 remainder — Postgres store, Docker client, warm pool, sessions route
Mirror to GitHub / mirror (push) Canceled after 0s
Mirror to GitHub / mirror (push) Canceled after 0s
- PostgresUserStore (Queryable) + migration + env-gated wiring - DockerodeClient behind injected DockerEngine - ImageManager, WarmPool, Dockerfile.wordpress, docker-compose - POST /sessions spins up sandbox (503 when unconfigured) - ADRs 0014-0015; 68 unit tests green
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildApp } from '../../src/app.ts';
|
||||
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
|
||||
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||
|
||||
class FakeDocker implements DockerClient {
|
||||
async createSandbox(): Promise<SandboxInfo> {
|
||||
return { id: 'sb-1', status: 'running' };
|
||||
}
|
||||
|
||||
async destroySandbox(): Promise<void> {}
|
||||
|
||||
async status(): Promise<SandboxInfo | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /sessions', () => {
|
||||
it('spins up a sandbox and returns a preview URL', async () => {
|
||||
const manager = new SandboxManager(new FakeDocker(), {
|
||||
image: 'wursor-base:latest',
|
||||
previewBaseUrl: 'https://preview.wursor.dev',
|
||||
});
|
||||
const app = await buildApp({ sandboxManager: manager });
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.sandboxId).toBe('sb-1');
|
||||
expect(body.previewUrl).toBe('https://preview.wursor.dev/sb-1');
|
||||
expect(body.sessionId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns 503 when no sandbox manager is configured', async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/sessions' });
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.json().error).toBe('sandbox_not_configured');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DockerodeClient, type ContainerLike, type DockerEngine } from '../../src/sandbox/dockerode-client.ts';
|
||||
|
||||
class FakeEngine implements DockerEngine {
|
||||
createdImages: string[] = [];
|
||||
names: string[] = [];
|
||||
removed: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly opts: { running?: boolean; failInspect?: boolean } = {},
|
||||
) {}
|
||||
|
||||
private container(): ContainerLike {
|
||||
return {
|
||||
start: async () => {},
|
||||
inspect: async () => {
|
||||
if (this.opts.failInspect) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
return { Id: 'id-1', State: { Running: this.opts.running ?? true } };
|
||||
},
|
||||
remove: async () => {
|
||||
this.removed.push('id-1');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createContainer(spec: { Image: string; name: string }): Promise<ContainerLike> {
|
||||
this.createdImages.push(spec.Image);
|
||||
this.names.push(spec.name);
|
||||
return this.container();
|
||||
}
|
||||
|
||||
getContainer(_id: string): ContainerLike {
|
||||
return this.container();
|
||||
}
|
||||
}
|
||||
|
||||
describe('DockerodeClient', () => {
|
||||
it('spins up a sandbox container from an image and reports it running', async () => {
|
||||
const engine = new FakeEngine();
|
||||
const client = new DockerodeClient(engine);
|
||||
|
||||
const info = await client.createSandbox('wursor-base:latest');
|
||||
expect(info).toEqual({ id: 'id-1', status: 'running' });
|
||||
expect(engine.createdImages).toEqual(['wursor-base:latest']);
|
||||
expect(engine.names[0]?.startsWith('wursor-')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports destroyed when the container is not running after start', async () => {
|
||||
const engine = new FakeEngine({ running: false });
|
||||
const client = new DockerodeClient(engine);
|
||||
|
||||
expect(await client.createSandbox('wursor-base:latest')).toEqual({ id: 'id-1', status: 'destroyed' });
|
||||
});
|
||||
|
||||
it('destroys a sandbox container', async () => {
|
||||
const engine = new FakeEngine();
|
||||
const client = new DockerodeClient(engine);
|
||||
|
||||
await client.destroySandbox('id-1');
|
||||
expect(engine.removed).toEqual(['id-1']);
|
||||
});
|
||||
|
||||
it('returns undefined status when the container is gone', async () => {
|
||||
const engine = new FakeEngine({ failInspect: true });
|
||||
const client = new DockerodeClient(engine);
|
||||
|
||||
expect(await client.status('id-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ImageManager } from '../../src/sandbox/image-manager.ts';
|
||||
|
||||
describe('ImageManager', () => {
|
||||
const manager = new ImageManager({ baseImage: 'wursor-base:latest', webPort: 8080 });
|
||||
|
||||
it('returns the pre-baked image ref', () => {
|
||||
expect(manager.imageRef()).toBe('wursor-base:latest');
|
||||
});
|
||||
|
||||
it('produces a container spec with the web port and management label', () => {
|
||||
expect(manager.containerSpec('wursor-abc')).toEqual({
|
||||
image: 'wursor-base:latest',
|
||||
name: 'wursor-abc',
|
||||
ports: [{ container: 80, host: 8080 }],
|
||||
labels: { 'wursor.managed': 'true' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PostgresUserStore } from '../../src/services/postgres-user-store.ts';
|
||||
import type { Queryable } from '../../src/services/postgres-user-store.ts';
|
||||
|
||||
class FakeDb implements Queryable {
|
||||
calls: Array<{ text: string; values?: unknown[] }> = [];
|
||||
rows: unknown[] = [];
|
||||
|
||||
async query(text: string, values?: unknown[]) {
|
||||
this.calls.push({ text, values });
|
||||
return { rows: this.rows };
|
||||
}
|
||||
}
|
||||
|
||||
describe('PostgresUserStore', () => {
|
||||
it('returns a user when the email exists', async () => {
|
||||
const db = new FakeDb();
|
||||
db.rows = [{ id: 'u1', email: 'a@example.com', password_hash: 'hash' }];
|
||||
const store = new PostgresUserStore(db);
|
||||
|
||||
const user = await store.findByEmail('a@example.com');
|
||||
expect(user).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||
expect(db.calls[0]?.text).toContain('SELECT');
|
||||
});
|
||||
|
||||
it('returns undefined when the email is absent', async () => {
|
||||
const db = new FakeDb();
|
||||
db.rows = [];
|
||||
const store = new PostgresUserStore(db);
|
||||
|
||||
expect(await store.findByEmail('nope@example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('inserts a new user and returns it', async () => {
|
||||
const db = new FakeDb();
|
||||
const store = new PostgresUserStore(db);
|
||||
|
||||
const created = await store.create({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||
expect(created).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
|
||||
const insert = db.calls[0];
|
||||
expect(insert?.text).toContain('INSERT INTO users');
|
||||
expect(insert?.values).toEqual(['u1', 'a@example.com', 'hash']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WarmPool, computeSpares } from '../../src/services/warm-pool.ts';
|
||||
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
|
||||
|
||||
class FakeDocker implements DockerClient {
|
||||
created: string[] = [];
|
||||
|
||||
async createSandbox(image: string): Promise<SandboxInfo> {
|
||||
this.created.push(image);
|
||||
return { id: `sb-${this.created.length}`, status: 'running' };
|
||||
}
|
||||
|
||||
async destroySandbox(): Promise<void> {}
|
||||
|
||||
async status(): Promise<SandboxInfo | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeSpares', () => {
|
||||
it('returns the shortfall', () => {
|
||||
expect(computeSpares(1, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('never returns a negative number', () => {
|
||||
expect(computeSpares(5, 2)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarmPool', () => {
|
||||
it('tops up the pool to the target hot-spare count', async () => {
|
||||
const docker = new FakeDocker();
|
||||
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||
|
||||
const result = await pool.topUp(0);
|
||||
expect(result.created).toBe(2);
|
||||
expect(docker.created).toEqual(['wursor-base:latest', 'wursor-base:latest']);
|
||||
});
|
||||
|
||||
it('does nothing when the pool is already full', async () => {
|
||||
const docker = new FakeDocker();
|
||||
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
|
||||
|
||||
const result = await pool.topUp(3);
|
||||
expect(result.created).toBe(0);
|
||||
expect(docker.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user