fix(consultation): count visible text only for session quota (BUG-732)
Replace append_consultation_question so the 200,000 quota sums only user-visible text, and add a 1,000,000-byte whole-JSON physical cap. Both still return session_full. Advisory lock, request_id idempotency, and the 200-message cap are unchanged.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
-- BUG-732: split append_consultation_question into two caps.
|
||||
-- Conversation quota (200,000) counts only user-visible `text`.
|
||||
-- thinkingText / thinkingSections stay stored but must not enter that sum.
|
||||
-- Physical cap counts the whole message JSON, including receipts.
|
||||
-- Arithmetic: 50 rounds × (~4,000 body + ~4,000 thinkingText + ~3,000
|
||||
-- thinkingSections + ~3,000 receipts) ≈ 700,000. Headroom → 1,000,000.
|
||||
-- Signature, return columns, error_code values, advisory lock, request_id
|
||||
-- idempotency, 200-message cap, and 16,000-char question check are unchanged
|
||||
-- (BUG-464). CREATE OR REPLACE is backward compatible with deployed callers.
|
||||
|
||||
begin;
|
||||
|
||||
do $migration$
|
||||
begin
|
||||
if current_user <> 'schema_owner' then
|
||||
raise exception 'consultation_session_capacity_requires_schema_owner'
|
||||
using errcode = '42501';
|
||||
end if;
|
||||
end
|
||||
$migration$;
|
||||
|
||||
create or replace function public.append_consultation_question(
|
||||
p_user_id uuid,
|
||||
p_request_id text,
|
||||
p_session_id uuid,
|
||||
p_question_message jsonb
|
||||
)
|
||||
returns table(success boolean, error_code text)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_session public.chat_sessions%rowtype;
|
||||
v_message jsonb;
|
||||
v_text text;
|
||||
v_request_id text;
|
||||
v_count integer;
|
||||
v_chars integer;
|
||||
v_new_chars integer;
|
||||
v_physical_chars bigint;
|
||||
v_new_physical bigint;
|
||||
begin
|
||||
v_request_id := btrim(coalesce(p_request_id, ''));
|
||||
if p_user_id is null or p_session_id is null or v_request_id = '' then
|
||||
return query select false, 'invalid_request'::text;
|
||||
return;
|
||||
end if;
|
||||
if jsonb_typeof(p_question_message) <> 'object'
|
||||
or p_question_message->>'role' <> 'user' then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
v_text := btrim(coalesce(p_question_message->>'text', ''));
|
||||
if v_text = '' or char_length(v_text) > 16000 then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
|
||||
|
||||
select session.* into v_session
|
||||
from public.chat_sessions as session
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation'
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
|
||||
where elem->>'requestId' = v_request_id
|
||||
) then
|
||||
return query select true, null::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
|
||||
|
||||
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0),
|
||||
coalesce(sum(length(elem::text)), 0)
|
||||
into v_chars, v_physical_chars
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
|
||||
|
||||
v_new_chars := length(v_text);
|
||||
v_new_physical := length(v_message::text);
|
||||
if v_count >= 200
|
||||
or (v_chars + v_new_chars) > 200000
|
||||
or (v_physical_chars + v_new_physical) > 1000000 then
|
||||
return query select false, 'session_full'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
update public.chat_sessions as session
|
||||
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
|
||||
title = case
|
||||
when coalesce(btrim(session.title), '') in ('', '新对话') then
|
||||
case
|
||||
when char_length(v_text) > 14 then left(v_text, 14) || '…'
|
||||
else v_text
|
||||
end
|
||||
else session.title
|
||||
end,
|
||||
updated_at = clock_timestamp()
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation';
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
return query select true, null::text;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to service_role;
|
||||
do $$ begin
|
||||
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to admin_runtime;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
|
||||
|
||||
const migrationsDir = new URL("../supabase/migrations/", import.meta.url);
|
||||
const originalSql = readFileSync(
|
||||
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const sql = readFileSync(
|
||||
new URL("../supabase/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const itemRoute = readFileSync(
|
||||
new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const consultRoute = readFileSync(
|
||||
new URL("../src/app/api/consult/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const SESSION_SELECT =
|
||||
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
|
||||
|
||||
function functionBody(source: string): string {
|
||||
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
|
||||
assert.ok(match?.[1], "function body must be present");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function withoutLineComments(source: string): string {
|
||||
return source.replace(/--[^\n]*/g, "");
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
function typicalReceipts(domains: readonly string[]) {
|
||||
const workflowReceipt = {
|
||||
route: "multi-domain",
|
||||
status: "ready",
|
||||
preciseTiming: "allowed",
|
||||
missingLayers: [] as string[],
|
||||
domains,
|
||||
};
|
||||
const agentExecutionReceipt = {
|
||||
runId: "run-typical",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology" as const,
|
||||
loaded: true,
|
||||
version: "1.0.0",
|
||||
referenceReads: 2,
|
||||
methodologySections: 4,
|
||||
},
|
||||
steps: [
|
||||
{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const, durationMs: 120 },
|
||||
{ sequence: 2, kind: "tool" as const, name: "run-jyotish-consultation", status: "completed" as const, durationMs: 1800 },
|
||||
],
|
||||
stepBudget: { planned: 8, used: 2, remaining: 6, truncated: false },
|
||||
workflow: workflowReceipt,
|
||||
techniqueTruth: "verified",
|
||||
techniqueAuditTable: [
|
||||
{ technique: "VedAstro Cloud State", status: "blocked" as const, note: "未经核验的官方云证据,置信度封顶" },
|
||||
{ technique: "Functional Benefic/Malefic", status: "executed" as const, note: "功能属性与自然属性冲突时降置信" },
|
||||
{ technique: "Formal Vargas D1–D60", status: "executed" as const, note: "20/20 传统命名分盘" },
|
||||
{ technique: "Vimshottari sub-periods", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "Narayana Dasha", status: "executed" as const, note: "本轮无独立当前读数" },
|
||||
{ technique: "Yogas", status: "executed" as const, note: "成盘与落空均列出" },
|
||||
{ technique: "Ashtakavarga", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "Shadbala components", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "MEVG / Global Web Evidence", status: "blocked" as const, note: "本轮未闭合" },
|
||||
{ technique: "Real Case Calibration", status: "blocked" as const, note: "本轮未闭合" },
|
||||
{ technique: "Timing Precision Gate", status: "executed" as const, note: "按声明精度封顶" },
|
||||
{ technique: "Prashna chart", status: "not_applicable" as const, note: "本轮为本命咨询" },
|
||||
],
|
||||
};
|
||||
return {
|
||||
techniqueTruth: "verified",
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
};
|
||||
}
|
||||
|
||||
function thinkingPlan(domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
return natalConsultationThinkingPlan({
|
||||
domains,
|
||||
requiredBlocks: [
|
||||
"raw_structure",
|
||||
"raman_six_step",
|
||||
"yoga_table",
|
||||
"timing",
|
||||
"synthesis",
|
||||
"technique_audit_table",
|
||||
"modern_wrap",
|
||||
],
|
||||
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
|
||||
});
|
||||
}
|
||||
|
||||
function assistantMessage(_index: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
const receipts = typicalReceipts(domains);
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
text: "正".repeat(4000),
|
||||
thinkingText: "思".repeat(4000),
|
||||
thinkingSections: thinkingPlan(domains),
|
||||
...receipts,
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(index: number) {
|
||||
return {
|
||||
role: "user" as const,
|
||||
text: `第${String(index + 1).padStart(2, "0")}问`.padEnd(100, "问"),
|
||||
requestId: `capacity-req-${index + 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionDetailPayload(rounds: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
const messages = Array.from({ length: rounds }, (_, index) => [
|
||||
userMessage(index),
|
||||
assistantMessage(index, domains),
|
||||
]).flat();
|
||||
return {
|
||||
session: {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
title: "事业方向",
|
||||
theme: "career",
|
||||
model_id: "test-model",
|
||||
messages,
|
||||
session_type: "consultation",
|
||||
rectification_case_id: null,
|
||||
chart_profile_id: null,
|
||||
chart_profile_name: null,
|
||||
chart_profile_role: null,
|
||||
updated_at: "2026-09-15T00:00:00.000Z",
|
||||
pinned: false,
|
||||
archived_at: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countedText(messages: readonly { text?: string }[]): number {
|
||||
return messages.reduce((sum, message) => sum + (message.text ?? "").length, 0);
|
||||
}
|
||||
|
||||
function oldCountedChars(messages: readonly Record<string, unknown>[]): number {
|
||||
return messages.reduce((sum, message) => {
|
||||
const text = typeof message.text === "string" ? message.text.length : 0;
|
||||
const thinking = typeof message.thinkingText === "string" ? message.thinkingText.length : 0;
|
||||
const sections = "thinkingSections" in message
|
||||
? JSON.stringify(message.thinkingSections).length
|
||||
: 0;
|
||||
return sum + text + thinking + sections;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
test("the capacity migration is the latest replace of append_consultation_question", () => {
|
||||
const names = readdirSync(fileURLToPath(migrationsDir))
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort();
|
||||
const replaces = names.filter((name) =>
|
||||
name.includes("append_consultation_question") || name.includes("consultation_session_capacity"),
|
||||
);
|
||||
assert.deepEqual(replaces.at(-1), "20260916010000_consultation_session_capacity.sql");
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
test("CREATE OR REPLACE keeps the BUG-464 signature, lock, idempotency, and error codes", () => {
|
||||
assert.match(
|
||||
sql,
|
||||
/create or replace function public\.append_consultation_question\(\s*p_user_id uuid,\s*p_request_id text,\s*p_session_id uuid,\s*p_question_message jsonb\s*\)/,
|
||||
);
|
||||
assert.match(sql, /returns table\(success boolean, error_code text\)/);
|
||||
const body = functionBody(sql);
|
||||
assert.match(body, /pg_advisory_xact_lock/);
|
||||
assert.match(body, /elem->>'requestId' = v_request_id/);
|
||||
assert.match(body, /return query select true, null::text;/);
|
||||
assert.match(body, /'session_missing'::text/);
|
||||
assert.match(body, /'session_full'::text/);
|
||||
assert.match(body, /'invalid_request'::text/);
|
||||
assert.match(body, /'invalid_question_message'::text/);
|
||||
assert.match(body, /char_length\(v_text\) > 16000/);
|
||||
assert.match(body, /v_count >= 200/);
|
||||
assert.match(body, /\(v_chars \+ v_new_chars\) > 200000/);
|
||||
assert.match(
|
||||
sql,
|
||||
/revoke all on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+from public, anon, authenticated/,
|
||||
);
|
||||
assert.match(
|
||||
sql,
|
||||
/grant execute on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+to service_role/,
|
||||
);
|
||||
assert.doesNotMatch(body, /session_physically_full|quota_exceeded|storage_full/);
|
||||
});
|
||||
|
||||
test("conversation quota sum counts only text; physical cap counts whole JSON", () => {
|
||||
assert.match(originalSql, /length\(coalesce\(elem->>'thinkingText', ''\)\)/);
|
||||
assert.match(originalSql, /elem \? 'thinkingSections'/);
|
||||
|
||||
const body = functionBody(sql);
|
||||
const code = withoutLineComments(body);
|
||||
assert.doesNotMatch(code, /thinkingText/);
|
||||
assert.doesNotMatch(code, /thinkingSections/);
|
||||
assert.match(code, /coalesce\(sum\(length\(coalesce\(elem->>'text', ''\)\)\), 0\)/);
|
||||
assert.match(code, /coalesce\(sum\(length\(elem::text\)\), 0\)/);
|
||||
assert.match(code, /\(v_physical_chars \+ v_new_physical\) > 1000000/);
|
||||
assert.match(sql, /50 rounds × \(~4,000 body \+ ~4,000 thinkingText \+ ~3,000/);
|
||||
assert.match(sql, /thinkingSections \+ ~3,000 receipts\) ≈ 700,000\. Headroom → 1,000,000\./);
|
||||
});
|
||||
|
||||
test("both caps still surface as session_full; consult route is unchanged", () => {
|
||||
const body = functionBody(sql);
|
||||
const fullReturns = body.match(/return query select false, 'session_full'::text;/g) ?? [];
|
||||
assert.equal(fullReturns.length, 1);
|
||||
assert.match(consultRoute, /error_code === "session_full"/);
|
||||
assert.match(consultRoute, /code: "session_full"/);
|
||||
assert.doesNotMatch(consultRoute, /session_physically_full|physical_cap|storage_full/);
|
||||
assert.match(itemRoute, new RegExp(`sessionSelect = "${SESSION_SELECT}"`));
|
||||
});
|
||||
|
||||
test("measured thinkingSections and receipts stay inside the physical-cap arithmetic", () => {
|
||||
const one = JSON.stringify(thinkingPlan(["career"]));
|
||||
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
|
||||
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
|
||||
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
|
||||
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
|
||||
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
|
||||
|
||||
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
|
||||
const receiptJson = JSON.stringify({
|
||||
techniqueTruth: receipts.techniqueTruth,
|
||||
workflowReceipt: receipts.workflowReceipt,
|
||||
agentExecutionReceipt: receipts.agentExecutionReceipt,
|
||||
});
|
||||
const receiptChars = receiptJson.length;
|
||||
assert.ok(
|
||||
receiptChars <= 4000,
|
||||
`typical three-receipt JSON is ${receiptChars} chars; update the 3,000 term and 1,000,000 cap if this is a real stored shape`,
|
||||
);
|
||||
|
||||
const perRound =
|
||||
4000
|
||||
+ 4000
|
||||
+ three.length
|
||||
+ receiptChars;
|
||||
const fiftyRoundPhysical = 50 * perRound;
|
||||
assert.ok(
|
||||
fiftyRoundPhysical < 1_000_000,
|
||||
`50 × typical stored round (${perRound} chars) = ${fiftyRoundPhysical}; must stay under the physical cap`,
|
||||
);
|
||||
});
|
||||
|
||||
test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap", () => {
|
||||
const domains = ["career", "wealth", "marriage"] as const;
|
||||
const oldCap = sessionDetailPayload(19, domains);
|
||||
const newCap = sessionDetailPayload(50, domains);
|
||||
const oldJson = JSON.stringify(oldCap);
|
||||
const newJson = JSON.stringify(newCap);
|
||||
const oldBytes = utf8Bytes(oldJson);
|
||||
const newBytes = utf8Bytes(newJson);
|
||||
|
||||
assert.equal(oldCap.session.messages.length, 38);
|
||||
assert.equal(newCap.session.messages.length, 100);
|
||||
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
|
||||
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
|
||||
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
|
||||
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
|
||||
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
|
||||
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
|
||||
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
|
||||
|
||||
const ratio = newBytes / oldBytes;
|
||||
assert.ok(
|
||||
ratio > 2.5 && ratio < 2.8,
|
||||
`50/19 size ratio was ${ratio.toFixed(3)} (utf8 ${oldBytes} → ${newBytes})`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
}).status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const SESSION_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function appendQuestionSql(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
textSql: string,
|
||||
sessionId = SESSION_ID,
|
||||
): string {
|
||||
return fixture.psql(`
|
||||
select coalesce(success::text, 'null') || ':' || coalesce(error_code, 'null')
|
||||
from public.append_consultation_question(
|
||||
'${userId}'::uuid,
|
||||
'${sqlLiteral(requestId)}',
|
||||
'${sessionId}'::uuid,
|
||||
jsonb_build_object('role', 'user', 'text', ${textSql})
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function appendQuestion(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
text: string,
|
||||
sessionId = SESSION_ID,
|
||||
): string {
|
||||
return appendQuestionSql(fixture, userId, requestId, `'${sqlLiteral(text)}'`, sessionId);
|
||||
}
|
||||
|
||||
function seedSession(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
messagesSql: string,
|
||||
): void {
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (
|
||||
id, user_id, title, theme, model_id, messages, session_type, updated_at
|
||||
) values (
|
||||
'${SESSION_ID}', '${userId}', '新对话', 'general',
|
||||
'test-model', ${messagesSql}, 'consultation', now()
|
||||
)
|
||||
on conflict (id) do update
|
||||
set messages = excluded.messages,
|
||||
title = excluded.title,
|
||||
updated_at = now();
|
||||
`);
|
||||
}
|
||||
|
||||
test("append_consultation_question ignores thinking fields and enforces the physical JSON cap", { skip: skipWithoutDocker }, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('Capacity User', 'capacity@example.com', true, now());
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'capacity@example.com'",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select
|
||||
has_function_privilege(
|
||||
'service_role',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'authenticated',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'anon',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
)
|
||||
`),
|
||||
"true:f:f",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "missing-session", "会话不存在"),
|
||||
"false:session_missing",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, "'[]'::jsonb");
|
||||
assert.equal(appendQuestion(fixture, userId, "append-request-1", "第一问会不会丢"), "true:null");
|
||||
assert.equal(appendQuestion(fixture, userId, "append-request-1", "不该写入的重复提问"), "true:null");
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
appendQuestionSql(fixture, userId, "too-long", "repeat('x', 16001)"),
|
||||
"false:invalid_question_message",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(elem order by n), '[]'::jsonb)
|
||||
from (
|
||||
select n,
|
||||
case when n % 2 = 1 then
|
||||
jsonb_build_object(
|
||||
'role', 'user',
|
||||
'text', '短问题',
|
||||
'requestId', 'think-seed-' || n::text
|
||||
)
|
||||
else
|
||||
jsonb_build_object(
|
||||
'role', 'assistant',
|
||||
'text', '短回答',
|
||||
'thinkingText', repeat('T', 4000),
|
||||
'thinkingSections', jsonb_build_object('pad', repeat('S', 3000)),
|
||||
'techniqueTruth', 'verified',
|
||||
'workflowReceipt', jsonb_build_object(
|
||||
'route', 'career',
|
||||
'status', 'ready',
|
||||
'preciseTiming', 'allowed',
|
||||
'missingLayers', jsonb_build_array()
|
||||
)
|
||||
)
|
||||
end as elem
|
||||
from generate_series(1, 60) as n
|
||||
) as seeded
|
||||
)
|
||||
`);
|
||||
|
||||
const thinkingSums = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(
|
||||
length(coalesce(elem->>'text', ''))
|
||||
+ length(coalesce(elem->>'thinkingText', ''))
|
||||
+ case when elem ? 'thinkingSections'
|
||||
then length((elem->'thinkingSections')::text) else 0 end
|
||||
), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [textOnly, oldFormula, physical] = thinkingSums.split(":").map(Number);
|
||||
assert.ok(textOnly < 50_000, `visible text was ${textOnly}`);
|
||||
assert.ok(oldFormula > 200_000, `old combined formula was ${oldFormula}`);
|
||||
assert.ok(physical < 1_000_000, `physical JSON was ${physical}`);
|
||||
assert.equal(appendQuestion(fixture, userId, "after-thinking", "思考不该占额度"), "true:null");
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"61",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object('role', 'assistant', 'text', repeat('x', 19900), 'requestId', n::text)
|
||||
order by n
|
||||
), '[]'::jsonb)
|
||||
from generate_series(1, 10) as n
|
||||
)
|
||||
`);
|
||||
const quotaBoundary = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [quotaText, quotaPhysical] = quotaBoundary.split(":").map(Number);
|
||||
assert.equal(quotaText, 199_000);
|
||||
assert.ok(quotaPhysical < 1_000_000, `quota-boundary physical JSON was ${quotaPhysical}`);
|
||||
assert.equal(
|
||||
appendQuestionSql(fixture, userId, "quota-full", "repeat('y', 1001)"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"10",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'role', 'assistant',
|
||||
'text', '短',
|
||||
'techniqueTruth', 'verified',
|
||||
'workflowReceipt', jsonb_build_object('route', 'career', 'status', 'ready', 'preciseTiming', 'allowed', 'missingLayers', jsonb_build_array()),
|
||||
'agentExecutionReceipt', jsonb_build_object('pad', repeat('R', 1000001))
|
||||
)
|
||||
)
|
||||
`);
|
||||
const physicalOnly = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [shortText, hugePhysical] = physicalOnly.split(":").map(Number);
|
||||
assert.ok(shortText < 100, `short body was ${shortText}`);
|
||||
assert.ok(hugePhysical > 1_000_000, `receipt pad physical JSON was ${hugePhysical}`);
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "physical-full", "正文很短但行已经胀了"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object('role', 'user', 'text', 'x', 'requestId', n::text)
|
||||
order by n
|
||||
), '[]'::jsonb)
|
||||
from generate_series(1, 200) as n
|
||||
)
|
||||
`);
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "count-full", "满了就不能再写"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"200",
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -94,6 +94,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260915010000_rectification_touch_chat_session\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))),
|
||||
false,
|
||||
@@ -104,6 +105,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
fixture.psql(pinArchiveMigration);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
Reference in New Issue
Block a user