diff --git a/src/server/clickhouse/client.ts b/src/server/clickhouse/client.ts index a8d8f43..1a7ef0f 100644 --- a/src/server/clickhouse/client.ts +++ b/src/server/clickhouse/client.ts @@ -1,8 +1,11 @@ import { createClient } from '@clickhouse/client' -export const clickhouse = createClient({ - url: process.env.CLICKHOUSE_URL, - username: process.env.CLICKHOUSE_USER, - password: process.env.CLICKHOUSE_PASSWORD, - database: 'newsletter', -}) +const url = process.env.CLICKHOUSE_URL +const username = process.env.CLICKHOUSE_USER +const password = process.env.CLICKHOUSE_PASSWORD + +if (!url || !username || !password) { + throw new Error('CLICKHOUSE_URL, CLICKHOUSE_USER und CLICKHOUSE_PASSWORD müssen gesetzt sein') +} + +export const clickhouse = createClient({ url, username, password, database: 'newsletter' }) diff --git a/src/server/db/tenant.test.ts b/src/server/db/tenant.test.ts index e2e79e9..84e8615 100644 --- a/src/server/db/tenant.test.ts +++ b/src/server/db/tenant.test.ts @@ -1,36 +1,65 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -vi.mock('./client', () => ({ - db: { - execute: vi.fn(), - query: vi.fn().mockResolvedValue([{ id: '1' }]), - }, +// Pool mocken — gibt einen fake PoolClient zurück +const mockClient = vi.hoisted(() => ({ + query: vi.fn(), + release: vi.fn(), +})) + +vi.mock('pg', () => ({ + Pool: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(mockClient), + })), })) import { withTenant } from './tenant' -import { db } from './client' describe('withTenant', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + mockClient.query.mockResolvedValue({ rows: [] }) + }) it('setzt search_path auf tenant-Schema', async () => { - await withTenant('abc123', () => db.query('SELECT 1', [])) - expect(db.execute).toHaveBeenCalledWith('SET search_path = tenant_abc123, public') + await withTenant('abc123', async (client) => { + await client.query('SELECT 1', []) + }) + expect(mockClient.query).toHaveBeenCalledWith('SET search_path = tenant_abc123, public') }) it('setzt search_path zurück auf public nach Ausführung', async () => { - await withTenant('abc123', () => db.query('SELECT 1', [])) - expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public') + await withTenant('abc123', async (client) => { + await client.query('SELECT 1', []) + }) + expect(mockClient.query).toHaveBeenLastCalledWith('SET search_path = public') }) it('setzt search_path zurück auch bei Fehler', async () => { - const fn = vi.fn().mockRejectedValue(new Error('DB-Fehler')) - await expect(withTenant('abc123', fn)).rejects.toThrow('DB-Fehler') - expect(db.execute).toHaveBeenLastCalledWith('SET search_path = public') + await expect( + withTenant('abc123', async () => { throw new Error('DB-Fehler') }) + ).rejects.toThrow('DB-Fehler') + expect(mockClient.query).toHaveBeenLastCalledWith('SET search_path = public') + expect(mockClient.release).toHaveBeenCalled() }) it('gibt Rückgabewert der Funktion zurück', async () => { const result = await withTenant('abc123', async () => 'testdata') expect(result).toBe('testdata') }) + + it('wirft bei ungültiger tenantId', async () => { + await expect( + withTenant('invalid-tenant!', async () => 'x') + ).rejects.toThrow('Ungültige tenantId') + }) + + it('gibt dieselbe Client-Instanz an fn weiter (Connection-Isolation)', async () => { + let capturedClient: unknown + await withTenant('tenant1', async (client) => { + capturedClient = client + }) + // Client muss nach withTenant released worden sein + expect(mockClient.release).toHaveBeenCalledOnce() + expect(capturedClient).toBeDefined() + }) }) diff --git a/src/server/db/tenant.ts b/src/server/db/tenant.ts index 63554de..6320ca9 100644 --- a/src/server/db/tenant.ts +++ b/src/server/db/tenant.ts @@ -1,11 +1,31 @@ -import { db } from './client' +import { Pool, type QueryResultRow } from 'pg' -export async function withTenant(tenantId: string, fn: () => Promise): Promise { +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +interface TenantClient { + execute: (sql: string) => Promise + query: >(sql: string, params: unknown[]) => Promise +} + +export async function withTenant( + tenantId: string, + fn: (client: TenantClient) => Promise +): Promise { + if (!/^[a-z0-9_]+$/.test(tenantId)) { + throw new Error(`Ungültige tenantId: ${tenantId}`) + } const schema = `tenant_${tenantId}` - await db.execute(`SET search_path = ${schema}, public`) + const client = await pool.connect() try { - return await fn() + await client.query(`SET search_path = ${schema}, public`) + const tenantClient: TenantClient = { + execute: async (sql) => { await client.query(sql) }, + query: async >(sql: string, params: unknown[]) => + client.query(sql, params).then((r) => r.rows), + } + return await fn(tenantClient) } finally { - await db.execute(`SET search_path = public`) + await client.query('SET search_path = public') + client.release() } }