diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b40f09..b99a54f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,27 @@ jobs: exit 1 fi + test-integration: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-node + - name: Create test env file + run: | + cat <<'EOF' > .env.test + POSTGRES_HOST=localhost + POSTGRES_VERSION=15 + POSTGRES_USER=core_test + POSTGRES_PASSWORD=core_test + POSTGRES_DB=core_test + POSTGRES_PORT=5433 + EOF + - run: docker compose --env-file .env.test -f docker-compose.test.yml up -d --wait + - run: npm run test:integration + - if: always() + run: docker compose --env-file .env.test -f docker-compose.test.yml down + build: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..0a7aa6a --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,16 @@ +services: + test-db: + image: "postgres:${POSTGRES_VERSION:-15}" #15のバージョン + ports: + - "${POSTGRES_PORT}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 10 + tmpfs: + - /var/lib/postgresql/data diff --git a/package-lock.json b/package-lock.json index 19aee33..9ce7353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1977,6 +1977,22 @@ } } }, + "node_modules/@voidzero-dev/vite-plus-test/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.1.14.tgz", @@ -3389,6 +3405,22 @@ } } }, + "node_modules/vite-plus/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c7b43f2..22c8a54 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "prepare": "npm run build", "test": "vp test", "test:watch": "vp test watch", + "test:integration": "vp test --config vite.config.integration.ts", + "test:integration:up": "docker compose --env-file .env.test -f docker-compose.test.yml up -d --wait && npm run test:integration", + "test:integration:down": "docker compose --env-file .env.test -f docker-compose.test.yml down", "typecheck": "tsc --noEmit", "check": "vp check", "lint": "vp lint", diff --git a/tests/helpers/config.ts b/tests/helpers/config.ts new file mode 100644 index 0000000..73672f3 --- /dev/null +++ b/tests/helpers/config.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; + +const TEST_ENV_PATH = fileURLToPath(new URL("../../.env.test", import.meta.url)); + +function loadTestEnv(): Record { + return parseEnv(readFileSync(TEST_ENV_PATH, "utf8")); +} + +function requireEnv(env: Record, name: string): string { + const value = env[name]; + if (!value) { + throw new Error(`${name} is not set in .env.test`); + } + return value; +} + +const loadedEnv = loadTestEnv(); + +for (const [key, value] of Object.entries(loadedEnv)) { + process.env[key] = value; +} + +export const testConfig = { + db: { + host: requireEnv(loadedEnv, "POSTGRES_HOST"), + port: Number(requireEnv(loadedEnv, "POSTGRES_PORT")), + name: requireEnv(loadedEnv, "POSTGRES_DB"), + user: requireEnv(loadedEnv, "POSTGRES_USER"), + password: requireEnv(loadedEnv, "POSTGRES_PASSWORD"), + }, +} as const; + +const databaseUrl = new URL( + `postgresql://${testConfig.db.host}:${testConfig.db.port}/${testConfig.db.name}`, +); +databaseUrl.username = testConfig.db.user; +databaseUrl.password = testConfig.db.password; + +export const TEST_DATABASE_URL = databaseUrl.toString(); + +process.env.DATABASE_URL = TEST_DATABASE_URL; + +export const testProcessEnv: NodeJS.ProcessEnv = { + ...process.env, + ...loadedEnv, + DATABASE_URL: TEST_DATABASE_URL, +}; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..9c12145 --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,79 @@ +import { execSync } from "node:child_process"; +import { getTableName, isTable } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { escapeIdentifier, Pool } from "pg"; +import type { DrizzleClient } from "#infrastructure/drizzle/client"; +import * as schema from "#infrastructure/drizzle/schema"; +import { TEST_DATABASE_URL, testConfig, testProcessEnv } from "./config"; + +/** スキーマ定義から _prisma_migrations を除いた全テーブル名を動的に取得 */ +const EXCLUDED_TABLES = new Set(["_prisma_migrations"]); +// TODO: #143 が解決されたら _prisma_migrations の暫定除外を削除する。 + +function getApplicationTableNames(): string[] { + const tableNames: string[] = []; + + for (const value of Object.values(schema) as unknown[]) { + if (!isTable(value)) continue; + tableNames.push(getTableName(value)); + } + + return tableNames.filter((name) => !EXCLUDED_TABLES.has(name)); +} + +let pool: Pool | null = null; +let client: DrizzleClient | null = null; + +/** テスト用プールを取得(なければ作成) */ +function getPool(): Pool { + if (!pool) { + pool = new Pool({ connectionString: TEST_DATABASE_URL }); + } + return pool; +} + +/** テスト用Drizzleクライアントを取得(アサーション用) */ +export function getTestClient(): DrizzleClient { + if (!client) { + client = drizzle(getPool(), { schema }); + } + return client; +} + +/** マイグレーションを実行してスキーマを最新にする */ +export async function setupTestDatabase(): Promise { + const p = getPool(); + + // 接続先がテスト用DBであることを確認(本番DBへの誤接続を防止) + const result = await p.query("SELECT current_database()"); + const dbName = result.rows[0].current_database; + if (dbName !== testConfig.db.name) { + throw new Error( + `テスト用DBではないDBに接続しています: "${dbName}"。接続先が ${testConfig.db.name} であることを確認してください。`, + ); + } + + // drizzle-kit pushでスキーマを同期(マイグレーションファイル不要で現在のスキーマを反映) + execSync("vp run db:push -- --force", { + env: testProcessEnv, + stdio: "pipe", + }); +} + +/** 全アプリケーションテーブルをTRUNCATEする */ +export async function cleanDatabase(): Promise { + const tableNames = getApplicationTableNames(); + if (tableNames.length === 0) return; + const p = getPool(); + const quotedTableNames = tableNames.map(escapeIdentifier); + await p.query(`TRUNCATE ${quotedTableNames.join(", ")} CASCADE`); +} + +/** テスト用プールを閉じる */ +export async function teardownTestDatabase(): Promise { + if (pool) { + await pool.end(); + pool = null; + client = null; + } +} diff --git a/tests/helpers/globalSetup.ts b/tests/helpers/globalSetup.ts new file mode 100644 index 0000000..3370b5b --- /dev/null +++ b/tests/helpers/globalSetup.ts @@ -0,0 +1,9 @@ +import { setupTestDatabase, teardownTestDatabase } from "./db"; + +export async function setup() { + await setupTestDatabase(); +} + +export async function teardown() { + await teardownTestDatabase(); +} diff --git a/tests/helpers/setupIntegration.ts b/tests/helpers/setupIntegration.ts new file mode 100644 index 0000000..8461c31 --- /dev/null +++ b/tests/helpers/setupIntegration.ts @@ -0,0 +1,7 @@ +import { beforeEach } from "vite-plus/test"; +import "./config"; +import { cleanDatabase } from "./db"; + +beforeEach(async () => { + await cleanDatabase(); +}); diff --git a/tests/infrastructure/drizzle/runInTransaction.integration.test.ts b/tests/infrastructure/drizzle/runInTransaction.integration.test.ts new file mode 100644 index 0000000..4a8d35e --- /dev/null +++ b/tests/infrastructure/drizzle/runInTransaction.integration.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import { eq } from "drizzle-orm"; +import { getTestClient } from "../../helpers/db"; +import { getClient, runInTransaction } from "#infrastructure/drizzle/client"; +import { events } from "#infrastructure/drizzle/schema"; + +function testEvent(id: string, name: string) { + const now = new Date().toISOString(); + return { id, name, date: now, updatedAt: now }; +} + +describe("runInTransaction", () => { + it("複数の書き込みがすべて永続化される", async () => { + await runInTransaction(async () => { + const client = getClient(); + await client.insert(events).values(testEvent("test-1", "Event 1")); + await client.insert(events).values(testEvent("test-2", "Event 2")); + }); + + const db = getTestClient(); + const rows = await db.select().from(events).where(eq(events.id, "test-1")); + const rows2 = await db.select().from(events).where(eq(events.id, "test-2")); + expect(rows).toHaveLength(1); + expect(rows2).toHaveLength(1); + }); + + it("途中で例外が発生したら全て巻き戻る", async () => { + await expect( + runInTransaction(async () => { + const client = getClient(); + await client.insert(events).values(testEvent("test-rollback", "Should not persist")); + throw new Error("intentional error"); + }), + ).rejects.toThrow("intentional error"); + + const db = getTestClient(); + const rows = await db.select().from(events).where(eq(events.id, "test-rollback")); + expect(rows).toHaveLength(0); + }); + + it("ネスト時に内側のrunInTransactionが新しいトランザクションを開始せず、外側の失敗で全体が巻き戻る", async () => { + await expect( + runInTransaction(async () => { + await runInTransaction(async () => { + const client = getClient(); + await client.insert(events).values(testEvent("test-nested", "Nested write")); + }); + + throw new Error("outer failure"); + }), + ).rejects.toThrow("outer failure"); + + const db = getTestClient(); + const rows = await db.select().from(events).where(eq(events.id, "test-nested")); + expect(rows).toHaveLength(0); + }); +}); diff --git a/vite.config.integration.ts b/vite.config.integration.ts new file mode 100644 index 0000000..37bc993 --- /dev/null +++ b/vite.config.integration.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + resolve: { + alias: { + "#domain": fileURLToPath(new URL("./src/domain", import.meta.url)), + "#application": fileURLToPath(new URL("./src/application", import.meta.url)), + "#infrastructure": fileURLToPath(new URL("./src/infrastructure", import.meta.url)), + }, + }, + test: { + include: ["tests/**/*.integration.test.ts"], + globalSetup: ["tests/helpers/globalSetup.ts"], + setupFiles: ["tests/helpers/setupIntegration.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + fileParallelism: false, + }, +}); diff --git a/vite.config.ts b/vite.config.ts index ee3df83..9e4f5cb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, test: { include: ["tests/**/*.test.ts"], + exclude: ["tests/**/*.integration.test.ts"], }, lint: { ignorePatterns: ["dist/**", "drizzle/**", "example/**"],