Skip to content

Commit 0fa7eb0

Browse files
committed
Add an integration test with real data
Usually our tests run with mock data, but that approach might miss DB changes, so new tests have been added to do a full setup with real data. Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 95d207b commit 0fa7eb0

4 files changed

Lines changed: 275 additions & 9 deletions

File tree

src/server/backend.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const loadConfig = (): IServerConfig => {
7979
: undefined;
8080

8181
// Environment variables take precedence over saved config.
82-
return {
82+
const merged = {
8383
...defaultConfig,
8484
...saved,
8585
host: process.env.HOST ?? saved.host ?? defaultConfig.host,
@@ -89,6 +89,20 @@ const loadConfig = (): IServerConfig => {
8989
? process.env.TRUST_PROXY === "true"
9090
: (saved.trustProxy ?? defaultConfig.trustProxy),
9191
};
92+
93+
// Database config overrides via environment variables.
94+
// These allow running the backend without persisting credentials on disk.
95+
merged.database = {
96+
...merged.database,
97+
engine: (process.env.DB_ENGINE as DatabaseEngine | undefined) ?? merged.database.engine,
98+
host: process.env.DB_HOST ?? merged.database.host,
99+
port: process.env.DB_PORT ? Number(process.env.DB_PORT) : merged.database.port,
100+
database: process.env.DB_NAME ?? merged.database.database,
101+
user: process.env.DB_USER ?? merged.database.user,
102+
password: process.env.DB_PASSWORD ?? merged.database.password,
103+
};
104+
105+
return merged;
92106
};
93107

94108
const saveConfig = (): void => {
@@ -554,13 +568,16 @@ const seedIfExists = async (targetAdapter: IDatabaseAdapter): Promise<void> => {
554568

555569
const handleTestConnection = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
556570
const user = getAuthUser(req);
557-
const usersExist = await hasUsers(adapter);
558571

559-
// Allow during bootstrap (no users yet) or when authenticated as admin.
560-
if (usersExist && (!user || !(await isUserInAdminGroup(adapter, user.userId)))) {
561-
sendError(res, "Forbidden", 403);
572+
// During bootstrap the adapter is not yet initialized — allow unrestricted.
573+
if (adapter.isInitialized()) {
574+
const usersExist = await hasUsers(adapter);
562575

563-
return;
576+
if (usersExist && (!user || !(await isUserInAdminGroup(adapter, user.userId)))) {
577+
sendError(res, "Forbidden", 403);
578+
579+
return;
580+
}
564581
}
565582

566583
const body = await readJsonBody(req);

src/server/mysql-adapter.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const createTablesSQL = [
6363
username VARCHAR(255) NOT NULL,
6464
password_hash VARCHAR(512) NOT NULL,
6565
refresh_token_hash VARCHAR(256) NULL,
66+
auth_type VARCHAR(16) NULL,
67+
group_id INT UNSIGNED NULL,
6668
display_name VARCHAR(255) NOT NULL,
6769
last_login TIMESTAMP NULL,
6870
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -71,6 +73,20 @@ const createTablesSQL = [
7173
UNIQUE KEY uk_users_username (username)
7274
) ENGINE=InnoDB`,
7375

76+
`CREATE TABLE IF NOT EXISTS login_audit (
77+
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
78+
user_id INT UNSIGNED NOT NULL,
79+
event ENUM('login', 'group_login', 'refresh', 'logout') NOT NULL,
80+
group_id INT UNSIGNED NULL,
81+
ip_address VARCHAR(45) NULL,
82+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
83+
PRIMARY KEY (id),
84+
INDEX idx_audit_user_time (user_id, created_at),
85+
CONSTRAINT fk_audit_user
86+
FOREIGN KEY (user_id) REFERENCES users(id)
87+
ON DELETE CASCADE
88+
) ENGINE=InnoDB`,
89+
7490
`CREATE TABLE IF NOT EXISTS \`groups\` (
7591
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
7692
name VARCHAR(255) NOT NULL,

src/server/postgres-adapter.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,23 @@ const createTablesSQL = [
4646
username VARCHAR(255) NOT NULL UNIQUE,
4747
password_hash VARCHAR(512) NOT NULL,
4848
refresh_token_hash VARCHAR(256),
49+
auth_type VARCHAR(16),
50+
group_id INT,
4951
display_name VARCHAR(255) NOT NULL,
50-
last_login TIMESTAMP,
51-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
52-
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
52+
last_login TIMESTAMP,
53+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
54+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
55+
)`,
56+
57+
`CREATE TABLE IF NOT EXISTS login_audit (
58+
id SERIAL PRIMARY KEY,
59+
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
60+
event VARCHAR(16) NOT NULL CHECK (event IN ('login', 'group_login', 'refresh', 'logout')),
61+
group_id INT,
62+
ip_address VARCHAR(45),
63+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
5364
)`,
65+
`CREATE INDEX IF NOT EXISTS idx_audit_user_time ON login_audit (user_id, created_at)`,
5466

5567
`CREATE TABLE IF NOT EXISTS groups (
5668
id SERIAL PRIMARY KEY,

tests/e2e/setup-real-db.spec.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
/* eslint-disable no-restricted-syntax */
7+
8+
import { expect, test } from "@playwright/test";
9+
import { ChildProcess, spawn } from "node:child_process";
10+
import { createConnection, type Connection } from "mysql2/promise";
11+
import { existsSync, mkdirSync } from "node:fs";
12+
import { resolve } from "node:path";
13+
14+
const testDbName = "animada_e2e_setup_test";
15+
const dbUser = "root";
16+
const dbPassword = "localRoot#123";
17+
const dbHost = "127.0.0.1";
18+
const dbPort = 3306;
19+
20+
// Use a different port to avoid conflicts with the dev server on 3100.
21+
const testBackendPort = 3199;
22+
const testBackendUrl = `http://127.0.0.1:${testBackendPort}`;
23+
24+
let backendProcess: ChildProcess | undefined;
25+
let dbConnection: Connection | undefined;
26+
27+
const waitForBackend = async (timeoutMs = 30000): Promise<void> => {
28+
const start = Date.now();
29+
30+
while (Date.now() - start < timeoutMs) {
31+
try {
32+
const response = await fetch(`${testBackendUrl}/api?action=health`);
33+
34+
if (response.ok) {
35+
return;
36+
}
37+
} catch {
38+
// Server not ready yet.
39+
}
40+
41+
await new Promise((r) => {
42+
setTimeout(r, 500);
43+
});
44+
}
45+
46+
throw new Error("Backend did not become ready within timeout");
47+
};
48+
49+
test.describe.serial("Setup: real database integration", () => {
50+
test.beforeAll(async () => {
51+
// Create a fresh test database.
52+
dbConnection = await createConnection({
53+
host: dbHost, port: dbPort, user: dbUser, password: dbPassword,
54+
});
55+
56+
await dbConnection.execute(`DROP DATABASE IF EXISTS \`${testDbName}\``);
57+
const createDb = `CREATE DATABASE \`${testDbName}\``
58+
+ " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
59+
60+
await dbConnection.execute(createDb);
61+
await dbConnection.end();
62+
dbConnection = undefined;
63+
64+
// Ensure uploads directory exists.
65+
const uploadsPath = resolve(process.cwd(), "public", "uploads", "instruments");
66+
67+
if (!existsSync(uploadsPath)) {
68+
mkdirSync(uploadsPath, { recursive: true });
69+
}
70+
71+
// The Playwright webServer already built dist/ — we just need the backend to serve it.
72+
73+
// Start the backend entirely via env vars. No config file manipulation.
74+
// A non-existent DB_NAME forces the setup dialog to appear.
75+
backendProcess = spawn("npx", ["tsx", "src/server/backend.ts"], {
76+
env: {
77+
...process.env,
78+
JWT_SECRET: "e2e-test-secret",
79+
PORT: String(testBackendPort),
80+
HOST: "127.0.0.1",
81+
DB_ENGINE: "mysql",
82+
DB_HOST: dbHost,
83+
DB_PORT: String(dbPort),
84+
DB_NAME: "nonexistent_db_to_force_setup_dialog",
85+
DB_USER: dbUser,
86+
DB_PASSWORD: "",
87+
},
88+
stdio: "pipe",
89+
});
90+
91+
backendProcess.stdout?.on("data", (data: Buffer) => {
92+
console.log(`[backend] ${data.toString().trim()}`);
93+
});
94+
95+
backendProcess.stderr?.on("data", (data: Buffer) => {
96+
console.error(`[backend:err] ${data.toString().trim()}`);
97+
});
98+
99+
await waitForBackend();
100+
});
101+
102+
test.afterAll(async () => {
103+
if (backendProcess) {
104+
backendProcess.kill("SIGTERM");
105+
await new Promise<void>((resolvePromise) => {
106+
backendProcess!.on("exit", () => {
107+
resolvePromise();
108+
});
109+
setTimeout(() => {
110+
resolvePromise();
111+
}, 5000);
112+
});
113+
}
114+
115+
dbConnection = await createConnection({
116+
host: dbHost, port: dbPort, user: dbUser, password: dbPassword,
117+
});
118+
await dbConnection.execute(`DROP DATABASE IF EXISTS \`${testDbName}\``);
119+
await dbConnection.end();
120+
});
121+
122+
test("full setup flow: no config → credentials → test → init → admin", async ({ page }) => {
123+
// Navigate directly to the test backend — it serves both API and frontend.
124+
await page.goto(testBackendUrl);
125+
await page.waitForSelector("#backendSetupDialog", { state: "visible", timeout: 10000 });
126+
127+
await expect(page.locator("#backendSetupDialog")).toContainText("Database Setup");
128+
129+
// Fill database credentials for the test database.
130+
const visibleInputs = page.locator("input:visible");
131+
132+
await visibleInputs.nth(0).fill(dbHost);
133+
await visibleInputs.nth(1).fill(String(dbPort));
134+
await visibleInputs.nth(2).fill(testDbName);
135+
await visibleInputs.nth(3).fill(dbUser);
136+
await visibleInputs.nth(4).fill(dbPassword);
137+
138+
// Click "Test Connection".
139+
await page.click("#backend-setup-test");
140+
await expect(page.locator(".text-success")).toBeVisible({ timeout: 15000 });
141+
await expect(page.locator(".text-success")).toContainText("Connection successful");
142+
143+
// Click "Initialize Database".
144+
await page.click("#backend-setup-init");
145+
146+
await expect(page.locator("#backendSetupDialog")).toContainText(
147+
"Database setup complete", { timeout: 30000 },
148+
);
149+
150+
// Close the setup dialog.
151+
await page.click("#backend-setup-close");
152+
153+
// The Admin Setup dialog should appear.
154+
await expect(page.locator("#adminSetupDialog")).toBeVisible({ timeout: 10000 });
155+
await expect(page.locator("#adminSetupDialog")).toContainText("Finish Installation");
156+
157+
// Fill and submit the admin creation form.
158+
await page.locator("#admin-username").fill("testadmin");
159+
await page.locator("#admin-password").fill("testpass123");
160+
await page.locator("#admin-confirm").fill("testpass123");
161+
await page.locator("#admin-display").fill("Test Admin");
162+
await page.locator("#admin-group").fill("Test Group");
163+
164+
await page.click("#admin-setup-create");
165+
166+
// After creation the app should load.
167+
await page.waitForTimeout(3000);
168+
});
169+
170+
test("schema verification: all tables and required columns exist", async () => {
171+
// Verify the backend reports initialized.
172+
const response = await fetch(`${testBackendUrl}/api?action=health`);
173+
const health = await response.json() as { initialized: boolean; };
174+
175+
expect(health.initialized).toBe(true);
176+
177+
// Verify schema by querying the test database directly.
178+
const conn = await createConnection({
179+
host: dbHost, port: dbPort, user: dbUser, password: dbPassword, database: testDbName,
180+
});
181+
182+
const tables = [
183+
"folders", "scores", "instruments", "instrument_images", "users",
184+
"login_audit", "groups", "user_groups", "permissions", "entity_groups",
185+
];
186+
187+
for (const table of tables) {
188+
const [rows] = await conn.query(
189+
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema = ? AND table_name = ?",
190+
[testDbName, table],
191+
) as [Array<{ cnt: number; }>, unknown];
192+
193+
expect(rows[0].cnt, `Table '${table}' should exist`).toBe(1);
194+
}
195+
196+
// Verify users table has required columns.
197+
const [columns] = await conn.query(
198+
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'users'",
199+
[testDbName],
200+
) as [Array<{ COLUMN_NAME: string; }>, unknown];
201+
202+
const columnNames = columns.map((c) => {
203+
return c.COLUMN_NAME;
204+
});
205+
206+
expect(columnNames).toContain("refresh_token_hash");
207+
expect(columnNames).toContain("auth_type");
208+
expect(columnNames).toContain("group_id");
209+
210+
// Verify the admin user was created.
211+
const [users] = await conn.query(
212+
"SELECT username, display_name FROM users WHERE username = ?",
213+
["testadmin"],
214+
) as [Array<{ username: string; display_name: string; }>, unknown];
215+
216+
expect(users.length).toBe(1);
217+
expect(users[0].username).toBe("testadmin");
218+
219+
await conn.end();
220+
});
221+
});

0 commit comments

Comments
 (0)