Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
KinjiKawaguchi marked this conversation as resolved.
Comment thread
KinjiKawaguchi marked this conversation as resolved.
- run: docker compose --env-file .env.test -f docker-compose.test.yml up -d --wait
- run: npm run test:integration
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
- if: always()
run: docker compose --env-file .env.test -f docker-compose.test.yml down

build:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
16 changes: 16 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions tests/helpers/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> {
return parseEnv(readFileSync(TEST_ENV_PATH, "utf8"));
}

function requireEnv(env: Record<string, string | undefined>, name: string): string {
const value = env[name];
if (!value) {
throw new Error(`${name} is not set in .env.test`);
}
return value;
}
Comment thread
KinjiKawaguchi marked this conversation as resolved.

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,
};
Comment thread
KinjiKawaguchi marked this conversation as resolved.
79 changes: 79 additions & 0 deletions tests/helpers/db.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Comment thread
KinjiKawaguchi marked this conversation as resolved.

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<void> {
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} であることを確認してください。`,
);
}
Comment on lines +50 to 54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Safety guard now compares against configurable DB name instead of hardcoded value

The old code at tests/helpers/db.ts:50 hardcoded dbName !== "core_test" as a safety check to prevent running tests against a production database. The new code compares dbName !== testConfig.db.name, where testConfig.db.name comes from .env.test. This means the safety guard is now tautological — it will always pass as long as the connection succeeds, because the configured name and actual name will always match when the connection string is derived from the same config. If someone accidentally configures .env.test to point at a production database, this guard won't catch it. The old hardcoded check provided a stronger safety net. Whether this matters depends on the team's risk tolerance for test database misuse.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これは、確かに...どうしようか

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo上に本番DBの情報直書きして防ぐみたいなのを思いましたがさすがにダメですよね...

DB接続先情報の一部のみハードに実装しますか?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localhostじゃなかったら警告するとかがいいかもしれない?


// drizzle-kit pushでスキーマを同期(マイグレーションファイル不要で現在のスキーマを反映)
execSync("vp run db:push -- --force", {
env: testProcessEnv,
stdio: "pipe",
});
}

/** 全アプリケーションテーブルをTRUNCATEする */
export async function cleanDatabase(): Promise<void> {
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<void> {
if (pool) {
await pool.end();
pool = null;
client = null;
}
}
9 changes: 9 additions & 0 deletions tests/helpers/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { setupTestDatabase, teardownTestDatabase } from "./db";

export async function setup() {
await setupTestDatabase();
}

export async function teardown() {
await teardownTestDatabase();
}
7 changes: 7 additions & 0 deletions tests/helpers/setupIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { beforeEach } from "vite-plus/test";
import "./config";
Comment thread
KinjiKawaguchi marked this conversation as resolved.
import { cleanDatabase } from "./db";

beforeEach(async () => {
await cleanDatabase();
});
Comment on lines +1 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Module import ordering ensures correct DATABASE_URL before pool creation

There's a subtle dependency: the production client.ts lazily reads process.env.DATABASE_URL only when getPool() is first called. The setupFiles config ensures setupIntegration.ts runs before test files, and it imports ./config which sets process.env.DATABASE_URL as a module-level side effect (tests/helpers/config.ts:43). Since the production pool is lazy (src/infrastructure/drizzle/client.ts:12-21), the env var is guaranteed to be set before the pool reads it. This works correctly but is fragile — if anyone imports the production client at module scope in a setupFile that runs before config, the pool would get an empty DATABASE_URL. A comment noting this ordering dependency would help future maintainers.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

57 changes: 57 additions & 0 deletions tests/infrastructure/drizzle/runInTransaction.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions vite.config.integration.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default defineConfig({
},
test: {
include: ["tests/**/*.test.ts"],
exclude: ["tests/**/*.integration.test.ts"],
},
lint: {
ignorePatterns: ["dist/**", "drizzle/**", "example/**"],
Expand Down