192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
import { expect, test, type APIResponse } from "@playwright/test";
|
|
import { useRealApi, withGlobalEngineConfigLock } from "./support";
|
|
|
|
interface HealthResponse {
|
|
db: boolean;
|
|
}
|
|
|
|
interface UserProfileResponse {
|
|
user_id: string;
|
|
email: string;
|
|
display_name: string;
|
|
role: string;
|
|
cohort_ids: string[];
|
|
affiliation: string;
|
|
}
|
|
|
|
interface UserPreferencesResponse {
|
|
theme: string;
|
|
voice_preset_id: string;
|
|
voice_rate: number;
|
|
notifications: {
|
|
session_done: boolean;
|
|
safety_signal: boolean;
|
|
learner_progress: boolean;
|
|
product_news: boolean;
|
|
};
|
|
}
|
|
|
|
interface AdminUsersResponse {
|
|
source: "database" | "server_session_registry";
|
|
durable: boolean;
|
|
users: UserProfileResponse[];
|
|
}
|
|
|
|
interface AdminEngineConfigResponse {
|
|
engine_mode: string;
|
|
engine_url: string;
|
|
model: string;
|
|
reasoning_effort: string | null;
|
|
updated_by: string | null;
|
|
updated_at: number | null;
|
|
}
|
|
|
|
async function expectResponseOk(response: APIResponse) {
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
}
|
|
|
|
function slugFor(testInfo: { project: { name: string }; workerIndex: number; retry: number }) {
|
|
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
|
|
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
}
|
|
|
|
test.describe("database-backed runtime state", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await useRealApi(page);
|
|
});
|
|
|
|
test("persists user settings and admin AI configuration through DB-backed APIs @single-run", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const healthResponse = await page.request.get("/api/health");
|
|
await expectResponseOk(healthResponse);
|
|
const health = (await healthResponse.json()) as HealthResponse;
|
|
test.skip(!health.db, "PostgreSQL is not connected for this run");
|
|
|
|
const slug = slugFor(testInfo);
|
|
const learnerEmail = `db-persist.${slug}@hs.ac.kr`.toLowerCase();
|
|
const learnerName = `DB Persist Learner ${testInfo.project.name}`;
|
|
const nextName = `DB Persist Updated ${testInfo.project.name}`;
|
|
const affiliation = "Hanshin University";
|
|
|
|
const learnerLogin = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: learnerEmail,
|
|
role: "learner",
|
|
display_name: learnerName,
|
|
},
|
|
});
|
|
await expectResponseOk(learnerLogin);
|
|
|
|
const profilePatch = await page.request.patch("/api/users/me", {
|
|
data: {
|
|
display_name: nextName,
|
|
affiliation,
|
|
},
|
|
});
|
|
await expectResponseOk(profilePatch);
|
|
const profile = (await profilePatch.json()) as UserProfileResponse;
|
|
expect(profile).toMatchObject({
|
|
email: learnerEmail,
|
|
display_name: nextName,
|
|
affiliation,
|
|
});
|
|
|
|
const preferencesPatch = await page.request.patch("/api/users/me/preferences", {
|
|
data: {
|
|
theme: "dark",
|
|
voice_rate: 1.1,
|
|
notifications: {
|
|
session_done: true,
|
|
safety_signal: true,
|
|
learner_progress: true,
|
|
product_news: true,
|
|
},
|
|
},
|
|
});
|
|
await expectResponseOk(preferencesPatch);
|
|
const preferences = (await preferencesPatch.json()) as UserPreferencesResponse;
|
|
expect(preferences.theme).toBe("dark");
|
|
expect(preferences.voice_rate).toBeCloseTo(1.1);
|
|
expect(preferences.notifications.product_news).toBe(true);
|
|
|
|
const adminEmail = `db-persist-admin.${slug}@twentyoz.kr`.toLowerCase();
|
|
const adminLogin = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: adminEmail,
|
|
role: "admin",
|
|
display_name: `DB Persist Admin ${testInfo.project.name}`,
|
|
},
|
|
});
|
|
await expectResponseOk(adminLogin);
|
|
|
|
const usersResponse = await page.request.get("/api/admin/users");
|
|
await expectResponseOk(usersResponse);
|
|
const users = (await usersResponse.json()) as AdminUsersResponse;
|
|
expect(users.source).toBe("database");
|
|
expect(users.durable).toBe(true);
|
|
const learner = users.users.find((user) => user.email === learnerEmail);
|
|
expect(learner, `Expected ${learnerEmail} in DB-backed admin list`).toBeTruthy();
|
|
expect(learner).toMatchObject({
|
|
display_name: nextName,
|
|
affiliation,
|
|
});
|
|
|
|
await withGlobalEngineConfigLock(`db-persistence-${slug}`, async () => {
|
|
const engineResponse = await page.request.get("/api/admin/engine-config");
|
|
await expectResponseOk(engineResponse);
|
|
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
|
|
const capabilityResponse = await page.request.get(
|
|
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(currentEngine.engine_mode)}`,
|
|
);
|
|
await expectResponseOk(capabilityResponse);
|
|
const capability = (await capabilityResponse.json()) as {
|
|
models: Array<{ id: string; default_reasoning_effort?: string | null }>;
|
|
};
|
|
const candidate =
|
|
capability.models.find((model) => model.id !== currentEngine.model) ?? capability.models[0];
|
|
if (!candidate) throw new Error("engine capability returned no selectable model");
|
|
const nextModel = candidate.id;
|
|
const nextEffort = candidate.default_reasoning_effort ?? null;
|
|
|
|
try {
|
|
const enginePatch = await page.request.patch("/api/admin/engine-config", {
|
|
data: {
|
|
engine_mode: currentEngine.engine_mode,
|
|
engine_url: currentEngine.engine_url,
|
|
model: nextModel,
|
|
reasoning_effort: nextEffort,
|
|
},
|
|
});
|
|
await expectResponseOk(enginePatch);
|
|
const updatedEngine = (await enginePatch.json()) as AdminEngineConfigResponse;
|
|
expect(updatedEngine).toMatchObject({
|
|
engine_mode: currentEngine.engine_mode,
|
|
engine_url: currentEngine.engine_url,
|
|
model: nextModel,
|
|
reasoning_effort: nextEffort,
|
|
updated_by: adminEmail,
|
|
});
|
|
expect(updatedEngine.updated_at).toBeGreaterThan(0);
|
|
|
|
const persistedEngineResponse = await page.request.get("/api/admin/engine-config");
|
|
await expectResponseOk(persistedEngineResponse);
|
|
const persistedEngine = (await persistedEngineResponse.json()) as AdminEngineConfigResponse;
|
|
expect(persistedEngine).toMatchObject({
|
|
model: nextModel,
|
|
updated_by: adminEmail,
|
|
});
|
|
} finally {
|
|
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
|
|
data: {
|
|
engine_mode: currentEngine.engine_mode,
|
|
engine_url: currentEngine.engine_url,
|
|
model: currentEngine.model,
|
|
reasoning_effort: currentEngine.reasoning_effort,
|
|
},
|
|
});
|
|
await expectResponseOk(restoreResponse);
|
|
}
|
|
});
|
|
});
|
|
});
|