1145 lines
39 KiB
TypeScript
1145 lines
39 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import {
|
|
spawn,
|
|
spawnSync,
|
|
type ChildProcessWithoutNullStreams,
|
|
} from "node:child_process";
|
|
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
import net from "node:net";
|
|
import { expect, test, type APIResponse, type Page } from "@playwright/test";
|
|
import {
|
|
acquireGlobalEngineConfigLock,
|
|
completeAlliancePreCheckpoint,
|
|
} from "./support";
|
|
|
|
const API_PORT = 8018;
|
|
const WEB_PORT = 5178;
|
|
const API_BASE = `http://127.0.0.1:${API_PORT}`;
|
|
const WEB_BASE = `http://127.0.0.1:${WEB_PORT}`;
|
|
const DB_CONTAINER = process.env.E2E_DB_CONTAINER ?? "vignette-dev-db";
|
|
|
|
interface ManagedProcess {
|
|
process: ChildProcessWithoutNullStreams;
|
|
logs: () => string;
|
|
}
|
|
|
|
interface ManagedServer {
|
|
server: Server;
|
|
url: string;
|
|
requests: () => string[];
|
|
}
|
|
|
|
interface EngineConfigSnapshot {
|
|
engine_mode: string;
|
|
engine_url: string;
|
|
model: string;
|
|
reasoning_effort: string | null;
|
|
updated_by: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
interface AdminEngineConfigResponse {
|
|
engine_mode: string;
|
|
engine_url: string;
|
|
model: string;
|
|
reasoning_effort: string | null;
|
|
durable: boolean;
|
|
source: string;
|
|
}
|
|
|
|
interface AuthMe {
|
|
user_id: string;
|
|
}
|
|
|
|
interface PersonaReviewSummary {
|
|
persona_id: string;
|
|
code: string;
|
|
version: number;
|
|
status: "draft" | "review" | "approved" | "archived";
|
|
display_name: string;
|
|
}
|
|
|
|
interface PersonaSummary {
|
|
persona_id: string | null;
|
|
code: string;
|
|
version: number | null;
|
|
status: "approved";
|
|
display_name: string;
|
|
presenting_summary: string;
|
|
source: string;
|
|
degraded: boolean;
|
|
}
|
|
|
|
interface SessionStartResponse {
|
|
session_id: string;
|
|
persona_id: string;
|
|
persona_version: number;
|
|
degraded: boolean;
|
|
}
|
|
|
|
interface SessionDetailResponse {
|
|
session_id: string;
|
|
persona_id: string;
|
|
persona_version: number;
|
|
persona_code: string;
|
|
persona_name: string;
|
|
turns: Array<{ speaker: "learner" | "client"; text: string }>;
|
|
}
|
|
|
|
interface ProtocolResponse {
|
|
protocol_id: string;
|
|
source_id: string;
|
|
title: string;
|
|
version: number;
|
|
license: "A" | "B" | "C" | "D";
|
|
external_llm_ok: boolean;
|
|
status: "draft" | "active" | "retired";
|
|
}
|
|
|
|
interface ProtocolActivationResponse {
|
|
protocol: ProtocolResponse;
|
|
chunks_indexed: number;
|
|
skipped_unchanged: boolean;
|
|
embedded: boolean;
|
|
degraded: boolean;
|
|
}
|
|
|
|
let engine: ManagedServer | null = null;
|
|
let api: ManagedProcess | null = null;
|
|
let web: ManagedProcess | null = null;
|
|
let engineConfigSnapshot: EngineConfigSnapshot | null = null;
|
|
let engineConfigSnapshotCaptured = false;
|
|
let releaseEngineConfigLock: (() => Promise<void>) | null = null;
|
|
|
|
const cleanup = {
|
|
sessionIds: new Set<string>(),
|
|
personaIds: new Set<string>(),
|
|
userIds: new Set<string>(),
|
|
emails: new Set<string>(),
|
|
protocolIds: new Set<string>(),
|
|
sourceIds: new Set<string>(),
|
|
};
|
|
|
|
function testSuffix(testInfo: { workerIndex: number; retry: number }): string {
|
|
return `${Date.now().toString(36)}-${testInfo.workerIndex}-${testInfo.retry}`;
|
|
}
|
|
|
|
async function expectOk(response: APIResponse): Promise<void> {
|
|
if (response.ok()) return;
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
}
|
|
|
|
async function readRequestBody(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function sendJson(response: import("node:http").ServerResponse, status: number, body: unknown) {
|
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
async function startControlledEngine(): Promise<ManagedServer> {
|
|
const requests: string[] = [];
|
|
const server = createServer(async (request, response) => {
|
|
requests.push(`${request.method ?? "UNKNOWN"} ${request.url ?? "/"}`);
|
|
try {
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
if (request.method === "GET" && ["/health", "/ready"].includes(url.pathname)) {
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
status: "ok",
|
|
detail: "REQ-005 controlled engine ready",
|
|
cached: false,
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/v1/capabilities") {
|
|
const provider = url.searchParams.get("provider") ?? "claude_api";
|
|
sendJson(response, 200, {
|
|
provider,
|
|
available: true,
|
|
source: "live_api",
|
|
models: [
|
|
{
|
|
id: "req005-controlled",
|
|
label: "REQ-005 controlled engine",
|
|
description: "Deterministic browser and PostgreSQL contract proof",
|
|
reasoning_efforts: ["low", "medium", "high"],
|
|
default_reasoning_effort: "medium",
|
|
is_default: true,
|
|
},
|
|
],
|
|
default_model: "req005-controlled",
|
|
default_reasoning_effort: "medium",
|
|
detail: "controlled engine ready",
|
|
fetched_at: Date.now() / 1000,
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === "DELETE" && url.pathname.startsWith("/session/")) {
|
|
sendJson(response, 200, { closed: true });
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/v1/generate") {
|
|
const payload = await readRequestBody(request);
|
|
sendJson(response, 200, {
|
|
text:
|
|
payload.ai_role === "evaluator"
|
|
? JSON.stringify({
|
|
techniques: [],
|
|
client_state_read: [],
|
|
appropriateness: "pos",
|
|
appropriateness_note: "실 DB 첫 턴 저장 확인",
|
|
rapport_signal: 0.2,
|
|
})
|
|
: "그렇게 천천히 물어봐 주니까 조금 더 말해볼 수 있을 것 같아요.",
|
|
model: "req005-controlled",
|
|
provider: String(payload.provider ?? "claude_api"),
|
|
tokens_in: 8,
|
|
tokens_out: 12,
|
|
cost_usd: 0,
|
|
structured:
|
|
payload.ai_role === "evaluator"
|
|
? {
|
|
techniques: [],
|
|
client_state_read: [],
|
|
appropriateness: "pos",
|
|
appropriateness_note: "실 DB 첫 턴 저장 확인",
|
|
rapport_signal: 0.2,
|
|
}
|
|
: null,
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/v1/stream") {
|
|
await readRequestBody(request);
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
});
|
|
response.write(
|
|
`event: token\ndata: ${JSON.stringify({ text: "그렇게 천천히 물어봐 주니까 " })}\n\n`,
|
|
);
|
|
response.write(
|
|
`event: token\ndata: ${JSON.stringify({ text: "조금 더 말해볼 수 있을 것 같아요." })}\n\n`,
|
|
);
|
|
response.end(
|
|
`event: done\ndata: ${JSON.stringify({
|
|
provider: "claude_api",
|
|
model: "req005-controlled",
|
|
tokens_in: 8,
|
|
tokens_out: 12,
|
|
cost_usd: 0,
|
|
turns: 1,
|
|
})}\n\n`,
|
|
);
|
|
return;
|
|
}
|
|
sendJson(response, 404, { detail: `unexpected engine path: ${url.pathname}` });
|
|
} catch (error) {
|
|
sendJson(response, 500, {
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
throw new Error("controlled engine did not expose a TCP port");
|
|
}
|
|
return {
|
|
server,
|
|
url: `http://127.0.0.1:${address.port}`,
|
|
requests: () => [...requests],
|
|
};
|
|
}
|
|
|
|
function python311(): string {
|
|
const local = process.env.USERPROFILE
|
|
? `${process.env.USERPROFILE}\\AppData\\Local\\Programs\\Python\\Python311\\python.exe`
|
|
: "";
|
|
return (
|
|
process.env.E2E_PYTHON ??
|
|
process.env.PYTHON311 ??
|
|
(local && existsSync(local) ? local : (process.env.PYTHON ?? "python"))
|
|
);
|
|
}
|
|
|
|
function startProcess(
|
|
executable: string,
|
|
args: string[],
|
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
|
): ManagedProcess {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
windowsHide: true,
|
|
});
|
|
let output = "";
|
|
const append = (chunk: Buffer | string) => {
|
|
output = `${output}${String(chunk)}`.slice(-12_000);
|
|
};
|
|
child.stdout.on("data", append);
|
|
child.stderr.on("data", append);
|
|
return { process: child, logs: () => output };
|
|
}
|
|
|
|
async function waitForHttp(url: string, managed: ManagedProcess, timeoutMs: number) {
|
|
const started = Date.now();
|
|
let lastError = "";
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (managed.process.exitCode !== null) {
|
|
throw new Error(
|
|
`process exited before ${url}: ${managed.process.exitCode}\n${managed.logs()}`,
|
|
);
|
|
}
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) return;
|
|
lastError = `HTTP ${response.status}: ${await response.text()}`;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
throw new Error(`timed out waiting for ${url}: ${lastError}\n${managed.logs()}`);
|
|
}
|
|
|
|
async function startApi(engineUrl: string): Promise<ManagedProcess> {
|
|
const managed = startProcess(
|
|
python311(),
|
|
[
|
|
"-X",
|
|
"utf8",
|
|
"-m",
|
|
"uvicorn",
|
|
"app.main:app",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
String(API_PORT),
|
|
"--log-level",
|
|
"warning",
|
|
],
|
|
{
|
|
cwd: "../api",
|
|
env: {
|
|
...process.env,
|
|
PYTHONUNBUFFERED: "1",
|
|
ENVIRONMENT: "dev",
|
|
AUTH_DEV_LOGIN_ENABLED: "true",
|
|
AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]',
|
|
AUTH_DEV_LOGIN_EXTRA_ORIGINS: JSON.stringify([WEB_BASE]),
|
|
AUTO_SEED_PERSONAS: "false",
|
|
ALLOW_SEED_PERSONA_FALLBACK: "false",
|
|
DB_POOL_MIN_SIZE: "1",
|
|
DB_POOL_MAX_SIZE: "4",
|
|
DB_COMMAND_TIMEOUT: "20",
|
|
ENGINE_URL: engineUrl,
|
|
ENGINE_MODE: "claude_api",
|
|
VIGNETTE_LIVE_CLIENT_PROVIDER: "claude_api",
|
|
ENGINE_TIMEOUT: "20",
|
|
ENGINE_CONNECT_TIMEOUT: "2",
|
|
EVALUATOR_SEMANTIC_CACHE_ENABLED: "false",
|
|
FRONTEND_BASE_URL: WEB_BASE,
|
|
CORS_ORIGINS: JSON.stringify([WEB_BASE]),
|
|
},
|
|
},
|
|
);
|
|
await waitForHttp(`${API_BASE}/health`, managed, 45_000);
|
|
return managed;
|
|
}
|
|
|
|
async function startWeb(): Promise<ManagedProcess> {
|
|
const managed = startProcess(
|
|
process.execPath,
|
|
["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", "--port", String(WEB_PORT)],
|
|
{
|
|
cwd: ".",
|
|
env: {
|
|
...process.env,
|
|
VITE_API_BASE: API_BASE,
|
|
},
|
|
},
|
|
);
|
|
await waitForHttp(WEB_BASE, managed, 45_000);
|
|
return managed;
|
|
}
|
|
|
|
async function stopProcess(managed: ManagedProcess | null): Promise<void> {
|
|
if (!managed || managed.process.exitCode !== null) return;
|
|
managed.process.kill();
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, 5_000);
|
|
managed.process.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function processExited(managed: ManagedProcess | null): boolean {
|
|
return (
|
|
managed === null ||
|
|
managed.process.exitCode !== null ||
|
|
managed.process.signalCode !== null
|
|
);
|
|
}
|
|
|
|
async function portIsListening(port: number): Promise<boolean> {
|
|
return new Promise<boolean>((resolve) => {
|
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
const done = (value: boolean) => {
|
|
socket.destroy();
|
|
resolve(value);
|
|
};
|
|
socket.setTimeout(500);
|
|
socket.once("connect", () => done(true));
|
|
socket.once("timeout", () => done(false));
|
|
socket.once("error", () => done(false));
|
|
});
|
|
}
|
|
|
|
async function expectPortClosed(port: number): Promise<void> {
|
|
await expect
|
|
.poll(() => portIsListening(port), {
|
|
timeout: 10_000,
|
|
intervals: [100, 250, 500],
|
|
})
|
|
.toBe(false);
|
|
}
|
|
|
|
function runPsql(sql: string): string {
|
|
const result = spawnSync(
|
|
"docker",
|
|
[
|
|
"exec",
|
|
"-i",
|
|
DB_CONTAINER,
|
|
"psql",
|
|
"-X",
|
|
"-U",
|
|
"vignette_owner",
|
|
"-d",
|
|
"vignette",
|
|
"-v",
|
|
"ON_ERROR_STOP=1",
|
|
"-A",
|
|
"-t",
|
|
"-F",
|
|
"|",
|
|
],
|
|
{ input: sql, encoding: "utf8", windowsHide: true },
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`psql failed (${result.status}): ${String(result.stderr).trim()}`);
|
|
}
|
|
return String(result.stdout).trim();
|
|
}
|
|
|
|
function sqlTextValue(value: string | null): string {
|
|
return value === null ? "NULL" : `'${value.replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function readEngineConfigSnapshot(): EngineConfigSnapshot | null {
|
|
const raw = runPsql(`
|
|
SELECT COALESCE(
|
|
(
|
|
SELECT json_build_object(
|
|
'engine_mode', engine_mode,
|
|
'engine_url', engine_url,
|
|
'model', model,
|
|
'reasoning_effort', reasoning_effort,
|
|
'updated_by', updated_by,
|
|
'updated_at', updated_at
|
|
)::text
|
|
FROM app.admin_engine_config
|
|
WHERE id = TRUE
|
|
),
|
|
'null'
|
|
);
|
|
`);
|
|
return raw === "null" ? null : (JSON.parse(raw) as EngineConfigSnapshot);
|
|
}
|
|
|
|
function installControlledEngineConfig(engineUrl: string): void {
|
|
runPsql(`
|
|
INSERT INTO app.admin_engine_config (
|
|
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
|
|
)
|
|
VALUES (
|
|
TRUE, 'claude_api', ${sqlTextValue(engineUrl)}, 'req005-controlled', 'medium',
|
|
'req005-e2e', now()
|
|
)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
engine_mode = EXCLUDED.engine_mode,
|
|
engine_url = EXCLUDED.engine_url,
|
|
model = EXCLUDED.model,
|
|
reasoning_effort = EXCLUDED.reasoning_effort,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = EXCLUDED.updated_at;
|
|
`);
|
|
expect(readEngineConfigSnapshot()).toMatchObject({
|
|
engine_mode: "claude_api",
|
|
engine_url: engineUrl,
|
|
model: "req005-controlled",
|
|
reasoning_effort: "medium",
|
|
updated_by: "req005-e2e",
|
|
});
|
|
}
|
|
|
|
function restoreEngineConfig(snapshot: EngineConfigSnapshot | null): void {
|
|
if (snapshot === null) {
|
|
runPsql("DELETE FROM app.admin_engine_config WHERE id = TRUE;");
|
|
} else {
|
|
runPsql(`
|
|
INSERT INTO app.admin_engine_config (
|
|
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
|
|
)
|
|
VALUES (
|
|
TRUE,
|
|
${sqlTextValue(snapshot.engine_mode)},
|
|
${sqlTextValue(snapshot.engine_url)},
|
|
${sqlTextValue(snapshot.model)},
|
|
${sqlTextValue(snapshot.reasoning_effort)},
|
|
${sqlTextValue(snapshot.updated_by)},
|
|
${sqlTextValue(snapshot.updated_at)}::timestamptz
|
|
)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
engine_mode = EXCLUDED.engine_mode,
|
|
engine_url = EXCLUDED.engine_url,
|
|
model = EXCLUDED.model,
|
|
reasoning_effort = EXCLUDED.reasoning_effort,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = EXCLUDED.updated_at;
|
|
`);
|
|
}
|
|
expect(readEngineConfigSnapshot()).toEqual(snapshot);
|
|
}
|
|
|
|
function sqlUuidArray(values: Iterable<string>): string {
|
|
const rows = [...values];
|
|
return rows.length ? `ARRAY[${rows.map((value) => `'${value}'::uuid`).join(",")}]` : "ARRAY[]::uuid[]";
|
|
}
|
|
|
|
function sqlTextArray(values: Iterable<string>): string {
|
|
const rows = [...values];
|
|
return rows.length
|
|
? `ARRAY[${rows.map((value) => `'${value.replaceAll("'", "''")}'::text`).join(",")}]`
|
|
: "ARRAY[]::text[]";
|
|
}
|
|
|
|
function cleanupTrackedRows(): void {
|
|
const sessionIds = sqlUuidArray(cleanup.sessionIds);
|
|
const personaIds = sqlUuidArray(cleanup.personaIds);
|
|
const userIds = sqlUuidArray(cleanup.userIds);
|
|
const emails = sqlTextArray(cleanup.emails);
|
|
const protocolIds = sqlUuidArray(cleanup.protocolIds);
|
|
const sourceIds = sqlTextArray(cleanup.sourceIds);
|
|
runPsql(`
|
|
BEGIN;
|
|
SET LOCAL session_replication_role = replica;
|
|
CREATE TEMP TABLE e2e_turn_ids AS
|
|
SELECT id FROM app.turns WHERE session_id = ANY(${sessionIds});
|
|
CREATE TEMP TABLE e2e_pulse_ids AS
|
|
SELECT pulse_id FROM app.alliance_pulse WHERE session_id = ANY(${sessionIds});
|
|
DO $cleanup$
|
|
DECLARE item record;
|
|
BEGIN
|
|
FOR item IN
|
|
SELECT table_schema, table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','kb')
|
|
AND column_name = 'turn_id'
|
|
LOOP
|
|
EXECUTE format(
|
|
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM e2e_turn_ids)',
|
|
item.table_schema, item.table_name, item.column_name
|
|
);
|
|
END LOOP;
|
|
FOR item IN
|
|
SELECT table_schema, table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','kb')
|
|
AND column_name = 'pulse_id'
|
|
LOOP
|
|
EXECUTE format(
|
|
'DELETE FROM %I.%I WHERE %I IN (SELECT pulse_id FROM e2e_pulse_ids)',
|
|
item.table_schema, item.table_name, item.column_name
|
|
);
|
|
END LOOP;
|
|
FOR item IN
|
|
SELECT table_schema, table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','kb')
|
|
AND column_name = 'session_id'
|
|
LOOP
|
|
EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name)
|
|
USING ${sessionIds};
|
|
END LOOP;
|
|
FOR item IN
|
|
SELECT table_schema, table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','kb')
|
|
AND column_name = 'persona_id'
|
|
LOOP
|
|
EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name)
|
|
USING ${personaIds};
|
|
END LOOP;
|
|
FOR item IN
|
|
SELECT table_schema, table_name, column_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','kb')
|
|
AND column_name IN (
|
|
'user_id','learner_id','actor_uid','created_by_uid','registered_by',
|
|
'approved_by','created_by','reviewer_id','recipient_user_id',
|
|
'subject_user_id','actor_user_id','owner_user_id','requested_by_uid'
|
|
)
|
|
AND data_type = 'uuid'
|
|
LOOP
|
|
EXECUTE format('DELETE FROM %I.%I WHERE %I = ANY($1)', item.table_schema, item.table_name, item.column_name)
|
|
USING ${userIds};
|
|
END LOOP;
|
|
END
|
|
$cleanup$;
|
|
DELETE FROM app.sessions WHERE id = ANY(${sessionIds});
|
|
DELETE FROM audit.audit_log
|
|
WHERE target_id = ANY(${sqlTextArray(cleanup.personaIds)})
|
|
OR actor_uid = ANY(${userIds});
|
|
DELETE FROM kb.chunk WHERE source_id = ANY(${sourceIds});
|
|
DELETE FROM kb.document WHERE source_id = ANY(${sourceIds});
|
|
DELETE FROM kb.source WHERE source_id = ANY(${sourceIds});
|
|
DELETE FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds});
|
|
DELETE FROM app.persona_card WHERE persona_id = ANY(${personaIds});
|
|
DELETE FROM app.auth_session WHERE user_id = ANY(${userIds});
|
|
DELETE FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails});
|
|
COMMIT;
|
|
SELECT
|
|
(SELECT count(*) FROM app.sessions WHERE id = ANY(${sessionIds})),
|
|
(SELECT count(*) FROM app.persona_card WHERE persona_id = ANY(${personaIds})),
|
|
(SELECT count(*) FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails})),
|
|
(SELECT count(*) FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds})),
|
|
(SELECT count(*) FROM kb.source WHERE source_id = ANY(${sourceIds}));
|
|
`);
|
|
const remaining = runPsql(`
|
|
SELECT
|
|
(SELECT count(*) FROM app.sessions WHERE id = ANY(${sessionIds})),
|
|
(SELECT count(*) FROM app.persona_card WHERE persona_id = ANY(${personaIds})),
|
|
(SELECT count(*) FROM app.app_user WHERE user_id = ANY(${userIds}) OR email = ANY(${emails})),
|
|
(SELECT count(*) FROM kb.protocol_registration WHERE protocol_id = ANY(${protocolIds})),
|
|
(SELECT count(*) FROM kb.source WHERE source_id = ANY(${sourceIds}));
|
|
`);
|
|
expect(remaining).toBe("0|0|0|0|0");
|
|
}
|
|
|
|
async function signIn(
|
|
page: Page,
|
|
role: "admin" | "teacher" | "learner",
|
|
email: string,
|
|
displayName: string,
|
|
): Promise<string> {
|
|
cleanup.emails.add(email);
|
|
const login = await page.request.post(`${API_BASE}/auth/dev-login`, {
|
|
data: {
|
|
email,
|
|
role,
|
|
display_name: displayName,
|
|
cohort_ids: ["req005-e2e"],
|
|
},
|
|
});
|
|
await expectOk(login);
|
|
const onboarding = await page.request.post(`${API_BASE}/users/me/onboarding`, {
|
|
data: {
|
|
legal_name: displayName,
|
|
affiliation: "한신대학교",
|
|
department: role === "admin" ? "운영" : "상담심리학과",
|
|
grade_level: role === "admin" ? "관리자" : role === "teacher" ? "교수" : "3학년",
|
|
phone: "010-0000-0000",
|
|
contact_address: "경기도 오산시 한신대학교",
|
|
nickname: displayName,
|
|
self_introduction: "REQ-005 실제 DB 브라우저 계약 검증 계정입니다.",
|
|
avatar_url: "",
|
|
terms_accepted: true,
|
|
privacy_accepted: true,
|
|
},
|
|
});
|
|
await expectOk(onboarding);
|
|
const me = await page.request.get(`${API_BASE}/auth/me`);
|
|
await expectOk(me);
|
|
const userId = ((await me.json()) as AuthMe).user_id;
|
|
cleanup.userIds.add(userId);
|
|
return userId;
|
|
}
|
|
|
|
test.describe("real PostgreSQL persona and protocol lifecycle", () => {
|
|
test.describe.configure({ mode: "default" });
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
expect(await portIsListening(API_PORT), `API port ${API_PORT} must be isolated`).toBe(false);
|
|
expect(await portIsListening(WEB_PORT), `web port ${WEB_PORT} must be isolated`).toBe(false);
|
|
const portMap = spawnSync("docker", ["port", DB_CONTAINER, "5432/tcp"], {
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
expect(portMap.status, String(portMap.stderr)).toBe(0);
|
|
expect(String(portMap.stdout)).toContain("55432");
|
|
expect(await portIsListening(55432), "PostgreSQL port 55432 must be reachable").toBe(true);
|
|
|
|
const release = await acquireGlobalEngineConfigLock(
|
|
"req005-persona-db-lifecycle",
|
|
);
|
|
releaseEngineConfigLock = release;
|
|
try {
|
|
engineConfigSnapshot = readEngineConfigSnapshot();
|
|
engineConfigSnapshotCaptured = true;
|
|
engine = await startControlledEngine();
|
|
installControlledEngineConfig(engine.url);
|
|
api = await startApi(engine.url);
|
|
|
|
const bootstrapEmail = `req005-engine-bootstrap-${Date.now()}@twentyoz.kr`;
|
|
cleanup.emails.add(bootstrapEmail);
|
|
const bootstrapLogin = await request.post(`${API_BASE}/auth/dev-login`, {
|
|
data: {
|
|
email: bootstrapEmail,
|
|
role: "admin",
|
|
display_name: "REQ005 엔진 격리 확인",
|
|
cohort_ids: ["req005-e2e"],
|
|
},
|
|
});
|
|
await expectOk(bootstrapLogin);
|
|
const bootstrapMe = await request.get(`${API_BASE}/auth/me`);
|
|
await expectOk(bootstrapMe);
|
|
cleanup.userIds.add(((await bootstrapMe.json()) as AuthMe).user_id);
|
|
const runtimeConfigResponse = await request.get(`${API_BASE}/admin/engine-config`);
|
|
await expectOk(runtimeConfigResponse);
|
|
expect((await runtimeConfigResponse.json()) as AdminEngineConfigResponse).toMatchObject({
|
|
engine_mode: "claude_api",
|
|
engine_url: engine.url,
|
|
model: "req005-controlled",
|
|
reasoning_effort: "medium",
|
|
durable: true,
|
|
source: "database",
|
|
});
|
|
|
|
// The isolated API has already configured its process-local EngineClient.
|
|
// Restore the shared DB row immediately so unrelated/public API startups
|
|
// cannot observe this one-shot localhost gateway during the browser run.
|
|
restoreEngineConfig(engineConfigSnapshot);
|
|
web = await startWeb();
|
|
} catch (error) {
|
|
let setupError: unknown = error;
|
|
try {
|
|
await stopProcess(web);
|
|
} catch (stopError) {
|
|
setupError ??= stopError;
|
|
}
|
|
try {
|
|
await stopProcess(api);
|
|
} catch (stopError) {
|
|
setupError ??= stopError;
|
|
}
|
|
try {
|
|
if (engine) {
|
|
await new Promise<void>((resolve) => engine?.server.close(() => resolve()));
|
|
}
|
|
} catch (closeError) {
|
|
setupError ??= closeError;
|
|
}
|
|
try {
|
|
cleanupTrackedRows();
|
|
} catch (cleanupError) {
|
|
setupError ??= cleanupError;
|
|
}
|
|
try {
|
|
if (engineConfigSnapshotCaptured) {
|
|
restoreEngineConfig(engineConfigSnapshot);
|
|
}
|
|
} catch (restoreError) {
|
|
setupError ??= restoreError;
|
|
}
|
|
try {
|
|
await release();
|
|
} catch (releaseError) {
|
|
setupError ??= releaseError;
|
|
}
|
|
releaseEngineConfigLock = null;
|
|
throw setupError;
|
|
}
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
let teardownError: unknown = null;
|
|
const remember = (error: unknown) => {
|
|
teardownError ??= error;
|
|
};
|
|
try {
|
|
await stopProcess(web);
|
|
expect(processExited(web), "the Vite process started by this spec must exit").toBe(true);
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
await stopProcess(api);
|
|
expect(processExited(api), "the uvicorn process started by this spec must exit").toBe(true);
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
if (engine) {
|
|
await new Promise<void>((resolve) => engine?.server.close(() => resolve()));
|
|
expect(engine.server.listening, "the controlled engine server must close").toBe(false);
|
|
}
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
cleanupTrackedRows();
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
if (engineConfigSnapshotCaptured) {
|
|
restoreEngineConfig(engineConfigSnapshot);
|
|
}
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
if (releaseEngineConfigLock) {
|
|
await releaseEngineConfigLock();
|
|
releaseEngineConfigLock = null;
|
|
}
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
try {
|
|
await expectPortClosed(WEB_PORT);
|
|
await expectPortClosed(API_PORT);
|
|
expect(await portIsListening(55432), "PostgreSQL port 55432 must remain reachable").toBe(
|
|
true,
|
|
);
|
|
} catch (error) {
|
|
remember(error);
|
|
}
|
|
if (teardownError) throw teardownError;
|
|
});
|
|
|
|
test("teacher browser creates and approves a persona that stays pinned through the learner's first stored turn @single-run", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.setTimeout(4 * 60_000);
|
|
page.setDefaultTimeout(30_000);
|
|
const suffix = testSuffix(testInfo);
|
|
const code = `P${Date.now().toString().slice(-8)}`;
|
|
const displayName = `은하(가명) · REQ005 ${suffix}`;
|
|
const teacherEmail = `req005-teacher-${suffix}@hs.ac.kr`;
|
|
const learnerEmail = `req005-learner-${suffix}@hs.ac.kr`;
|
|
const learnerText = "그 이야기를 꺼내는 동안 어떤 마음이 가장 크게 느껴졌나요?";
|
|
const complaint = "사람들 앞에서 말한 뒤 계속 실수를 떠올려요.";
|
|
const surfaceCondition =
|
|
"상담자가 침묵을 기다리고 감정을 반영할 때 발표 장면을 구체적으로 말한다.";
|
|
|
|
await signIn(page, "teacher", teacherEmail, `REQ005 교수 ${suffix}`);
|
|
await page.goto(`${WEB_BASE}/teach/personas?view=personas`);
|
|
await expect(page.getByRole("heading", { name: "페르소나 관리" })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click();
|
|
await expect(page.getByRole("heading", { name: "새 페르소나" })).toBeVisible();
|
|
await page
|
|
.getByRole("navigation", { name: "페르소나 작성 단계" })
|
|
.getByRole("button", { name: /설정/ })
|
|
.click();
|
|
await expect(page.getByLabel("코드", { exact: true })).toBeVisible();
|
|
|
|
await page.getByLabel("코드", { exact: true }).fill(code);
|
|
await page.getByLabel("표시 이름", { exact: true }).fill(displayName);
|
|
await page.getByLabel("연령대", { exact: true }).fill("20대");
|
|
await page.getByLabel("역할/학년", { exact: true }).fill("대학생");
|
|
await page.getByLabel("맥락", { exact: true }).fill("발표 이후 대인관계 불안이 높아짐");
|
|
await page.getByLabel("주호소", { exact: true }).fill(complaint);
|
|
await page
|
|
.getByLabel("첫 회기 입장 발화", { exact: true })
|
|
.fill("무슨 말부터 해야 할지 잘 모르겠어요.");
|
|
await page.getByRole("tab", { name: "임상", exact: true }).click();
|
|
await page.getByLabel("촉발 사건", { exact: true }).fill("최근 발표에서 말을 더듬은 경험");
|
|
await page.getByLabel("핵심신념", { exact: true }).fill("실수하면 사람들에게 받아들여지지 못한다.");
|
|
const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" });
|
|
await automaticThoughts
|
|
.getByRole("textbox", { name: "자동사고 1", exact: true })
|
|
.fill("또 실수할 거야");
|
|
await page.getByRole("tab", { name: "회기", exact: true }).click();
|
|
await page.getByLabel("표면화 조건", { exact: true }).fill(surfaceCondition);
|
|
await page.getByRole("tab", { name: "안전", exact: true }).click();
|
|
const soreSpots = page.locator(".ps-list-field").filter({ hasText: "역린 민감 영역" });
|
|
await soreSpots
|
|
.getByRole("textbox", { name: "역린 민감 영역 1", exact: true })
|
|
.fill("공개적인 비교와 성급한 조언");
|
|
const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" });
|
|
await forbidden
|
|
.getByRole("textbox", { name: "상담자 금기 1", exact: true })
|
|
.fill("노력하면 된다는 단정");
|
|
await page.getByLabel("역린 반응", { exact: true }).fill("대답이 짧아지고 시선을 피한다.");
|
|
await page
|
|
.getByRole("navigation", { name: "페르소나 작성 단계" })
|
|
.getByRole("button", { name: /검토/ })
|
|
.click();
|
|
|
|
const createResponsePromise = page.waitForResponse((response) => {
|
|
const url = new URL(response.url());
|
|
return response.request().method() === "POST" && url.pathname === "/personas/drafts";
|
|
});
|
|
await page.getByRole("button", { name: "검수 요청", exact: true }).click();
|
|
const createResponse = await createResponsePromise;
|
|
expect(createResponse.status()).toBe(201);
|
|
const created = (await createResponse.json()) as PersonaReviewSummary;
|
|
cleanup.personaIds.add(created.persona_id);
|
|
expect(created).toMatchObject({
|
|
code,
|
|
version: 1,
|
|
status: "review",
|
|
display_name: displayName,
|
|
});
|
|
await expect(page.getByText(`${code} v1 검수 요청을 올렸습니다.`)).toBeVisible();
|
|
|
|
await page.goto(`${WEB_BASE}/teach/personas?view=personas&queue=review`);
|
|
const reviewCard = page.locator(".ps-review-card").filter({ hasText: code });
|
|
await expect(reviewCard).toContainText(displayName);
|
|
const approveResponsePromise = page.waitForResponse((response) => {
|
|
const url = new URL(response.url());
|
|
return (
|
|
response.request().method() === "POST" &&
|
|
url.pathname === `/personas/review/${created.persona_id}`
|
|
);
|
|
});
|
|
await reviewCard.getByRole("button", { name: "승인", exact: true }).click();
|
|
const approveResponse = await approveResponsePromise;
|
|
await expectOk(approveResponse);
|
|
expect((await approveResponse.json()) as PersonaReviewSummary).toMatchObject({
|
|
persona_id: created.persona_id,
|
|
code,
|
|
version: created.version,
|
|
status: "approved",
|
|
});
|
|
|
|
await page.goto(`${WEB_BASE}/teach/personas?view=catalog`);
|
|
const catalogCard = page.locator(".ps-catalog-list > button").filter({ hasText: code });
|
|
await expect(catalogCard).toContainText(displayName);
|
|
await expect(catalogCard.locator("strong")).toHaveText(`v${created.version}`);
|
|
const catalogResponse = await page.request.get(`${API_BASE}/personas`);
|
|
await expectOk(catalogResponse);
|
|
const catalog = (await catalogResponse.json()) as PersonaSummary[];
|
|
const approvedPersona = catalog.find((item) => item.code === code);
|
|
expect(approvedPersona).toMatchObject({
|
|
persona_id: created.persona_id,
|
|
code,
|
|
version: created.version,
|
|
status: "approved",
|
|
display_name: displayName,
|
|
presenting_summary: complaint,
|
|
source: "database",
|
|
degraded: false,
|
|
});
|
|
expect(approvedPersona?.presenting_summary).toContain(complaint);
|
|
expect(approvedPersona?.presenting_summary).not.toContain(surfaceCondition);
|
|
|
|
await signIn(page, "learner", learnerEmail, `REQ005 학습자 ${suffix}`);
|
|
await page.goto(`${WEB_BASE}/learn/practice`);
|
|
const personaOption = page.getByRole("option").filter({ hasText: code });
|
|
await expect(personaOption).toContainText(displayName, { timeout: 30_000 });
|
|
await personaOption.click();
|
|
await expect(personaOption).toHaveAttribute("aria-selected", "true");
|
|
await page.getByRole("button", { name: "새 회기 시작", exact: true }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${code}$`));
|
|
const prestartFacts = page.locator(".sx-prestart__facts");
|
|
await expect(prestartFacts.getByText("호소", { exact: true })).toBeVisible();
|
|
await expect(prestartFacts.getByText(complaint, { exact: true })).toBeVisible();
|
|
await expect(prestartFacts).not.toContainText(surfaceCondition);
|
|
|
|
const startResponsePromise = page.waitForResponse((response) => {
|
|
const url = new URL(response.url());
|
|
return response.request().method() === "POST" && url.pathname === "/sessions";
|
|
});
|
|
await page.getByRole("button", { name: "회기 시작", exact: true }).click();
|
|
const startResponse = await startResponsePromise;
|
|
expect(startResponse.status()).toBe(201);
|
|
const started = (await startResponse.json()) as SessionStartResponse;
|
|
cleanup.sessionIds.add(started.session_id);
|
|
expect(started).toMatchObject({
|
|
persona_id: created.persona_id,
|
|
persona_version: created.version,
|
|
degraded: false,
|
|
});
|
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${started.session_id}$`));
|
|
await completeAlliancePreCheckpoint(page);
|
|
|
|
const streamResponsePromise = page.waitForResponse((response) => {
|
|
const url = new URL(response.url());
|
|
return (
|
|
response.request().method() === "POST" &&
|
|
url.pathname === `/sessions/${started.session_id}/stream`
|
|
);
|
|
});
|
|
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
|
await page.getByRole("button", { name: "보내기", exact: true }).click();
|
|
const streamResponse = await streamResponsePromise;
|
|
await expectOk(streamResponse);
|
|
expect(streamResponse.headers()["content-type"] ?? "").toContain("text/event-stream");
|
|
const engineRequests = engine?.requests() ?? [];
|
|
const streamDiagnostics = [
|
|
`engine=${engineRequests.join(" | ") || "<none>"}`,
|
|
`api-tail=${api?.logs() || "<none>"}`,
|
|
].join("\n");
|
|
await testInfo.attach("req005-stream-wire.txt", {
|
|
body: streamDiagnostics,
|
|
contentType: "text/plain; charset=utf-8",
|
|
});
|
|
expect(
|
|
engineRequests.some((request) => request.startsWith("POST /v1/stream")),
|
|
streamDiagnostics,
|
|
).toBe(true);
|
|
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
|
|
await expect(page.locator(".sx-utt.is-client").last()).toContainText(
|
|
"조금 더 말해볼 수 있을 것 같아요.",
|
|
{ timeout: 30_000 },
|
|
);
|
|
|
|
const detailResponse = await page.request.get(`${API_BASE}/sessions/${started.session_id}`);
|
|
await expectOk(detailResponse);
|
|
const detail = (await detailResponse.json()) as SessionDetailResponse;
|
|
expect(detail).toMatchObject({
|
|
session_id: started.session_id,
|
|
persona_id: created.persona_id,
|
|
persona_version: created.version,
|
|
persona_code: code,
|
|
persona_name: displayName,
|
|
});
|
|
expect(detail.turns).toHaveLength(2);
|
|
expect(detail.turns.map((turn) => turn.speaker)).toEqual(["learner", "client"]);
|
|
expect(detail.turns[0].text).toContain("어떤 마음이 가장 크게 느껴졌나요");
|
|
|
|
const dbEvidence = runPsql(`
|
|
SELECT
|
|
s.persona_id::text,
|
|
s.persona_version,
|
|
pc.code,
|
|
pc.version,
|
|
count(t.id),
|
|
string_agg(t.speaker, ',' ORDER BY t.seq)
|
|
FROM app.sessions s
|
|
JOIN app.persona_card pc
|
|
ON pc.persona_id = s.persona_id AND pc.version = s.persona_version
|
|
LEFT JOIN app.turns t ON t.session_id = s.id
|
|
WHERE s.id = '${started.session_id}'::uuid
|
|
GROUP BY s.persona_id, s.persona_version, pc.code, pc.version;
|
|
`);
|
|
expect(dbEvidence).toBe(
|
|
`${created.persona_id}|${created.version}|${code}|${created.version}|2|counselor,client`,
|
|
);
|
|
});
|
|
|
|
test("admin API activates and retires a protocol with the matching real KB document lifecycle @single-run", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.setTimeout(2 * 60_000);
|
|
const suffix = testSuffix(testInfo);
|
|
const adminEmail = `req002-admin-${suffix}@twentyoz.kr`;
|
|
const title = `REQ002 실제 수명주기 ${suffix}`;
|
|
await signIn(page, "admin", adminEmail, `REQ002 관리자 ${suffix}`);
|
|
|
|
const create = await page.request.post(`${API_BASE}/admin/protocols`, {
|
|
data: {
|
|
title,
|
|
source: `repo://e2e/protocol/${suffix}`,
|
|
version: 1,
|
|
license: "A",
|
|
external_llm_ok: true,
|
|
content:
|
|
"첫 문단은 상담자가 내담자의 안전 신호를 관찰하는 절차를 설명합니다.\n\n두 번째 문단은 평가자 전용 근거와 기록 순서를 설명합니다.",
|
|
},
|
|
});
|
|
expect(create.status(), await create.text()).toBe(201);
|
|
const draft = (await create.json()) as ProtocolResponse;
|
|
cleanup.protocolIds.add(draft.protocol_id);
|
|
cleanup.sourceIds.add(draft.source_id);
|
|
expect(draft).toMatchObject({
|
|
title,
|
|
version: 1,
|
|
license: "A",
|
|
external_llm_ok: true,
|
|
status: "draft",
|
|
});
|
|
|
|
const activate = await page.request.post(
|
|
`${API_BASE}/admin/protocols/${draft.protocol_id}/activate`,
|
|
);
|
|
await expectOk(activate);
|
|
const activated = (await activate.json()) as ProtocolActivationResponse;
|
|
expect(activated.protocol).toMatchObject({
|
|
protocol_id: draft.protocol_id,
|
|
source_id: draft.source_id,
|
|
status: "active",
|
|
});
|
|
expect(activated.chunks_indexed).toBeGreaterThanOrEqual(1);
|
|
|
|
const activeList = await page.request.get(
|
|
`${API_BASE}/admin/protocols?status=active&search=${encodeURIComponent(title)}`,
|
|
);
|
|
await expectOk(activeList);
|
|
expect((await activeList.json()) as { total: number; protocols: ProtocolResponse[] }).toMatchObject({
|
|
total: 1,
|
|
protocols: [
|
|
{
|
|
protocol_id: draft.protocol_id,
|
|
source_id: draft.source_id,
|
|
status: "active",
|
|
},
|
|
],
|
|
});
|
|
const activeDb = runPsql(`
|
|
SELECT
|
|
pr.status,
|
|
bool_and(d.is_active),
|
|
count(c.chunk_id),
|
|
min(c.sensitivity),
|
|
bool_and(c.visible_to = ARRAY['evaluator']::text[]),
|
|
s.license_class,
|
|
s.external_llm_ok
|
|
FROM kb.protocol_registration pr
|
|
JOIN kb.source s ON s.source_id = pr.source_id
|
|
JOIN kb.document d ON d.source_id = pr.source_id
|
|
JOIN kb.chunk c ON c.doc_id = d.doc_id
|
|
WHERE pr.protocol_id = '${draft.protocol_id}'::uuid
|
|
GROUP BY pr.status, s.license_class, s.external_llm_ok;
|
|
`);
|
|
expect(activeDb).toMatch(/^active\|t\|[1-9][0-9]*\|2\|t\|A\|t$/);
|
|
|
|
const retire = await page.request.post(
|
|
`${API_BASE}/admin/protocols/${draft.protocol_id}/retire`,
|
|
);
|
|
await expectOk(retire);
|
|
expect((await retire.json()) as ProtocolResponse).toMatchObject({
|
|
protocol_id: draft.protocol_id,
|
|
source_id: draft.source_id,
|
|
status: "retired",
|
|
});
|
|
const retiredDb = runPsql(`
|
|
SELECT pr.status, bool_or(d.is_active)
|
|
FROM kb.protocol_registration pr
|
|
JOIN kb.document d ON d.source_id = pr.source_id
|
|
WHERE pr.protocol_id = '${draft.protocol_id}'::uuid
|
|
GROUP BY pr.status;
|
|
`);
|
|
expect(retiredDb).toBe("retired|f");
|
|
});
|
|
});
|