fix: restore mobile report scrolling and published product edits
Report pages now scroll inside the chat shell lock, and admin product save forks a draft or retires a published plan instead of rejecting with a generic constraint error. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -187,6 +187,19 @@ test("adminErrorResponse maps model provider configuration failures without expo
|
||||
}
|
||||
});
|
||||
|
||||
test("adminErrorResponse maps product catalog failures without exposing details", async () => {
|
||||
for (const [error, status, message] of [
|
||||
[new Error("product_not_found"), 404, "商品不存在"],
|
||||
[new Error("product_code_immutable"), 400, "商品代码不可修改"],
|
||||
[new Error("product_draft_not_found"), 400, "只能编辑草稿。已发布商品请下架,或保存为新版本草稿后再发布"],
|
||||
[{ code: "23503", message: "insert or update on table violates foreign key constraint" }, 409, "已有订单或订阅引用该商品,不能硬删除,请改为下架"],
|
||||
] as const) {
|
||||
const response = adminErrorResponse(error);
|
||||
assert.equal(response.status, status);
|
||||
assert.deepEqual(await response.json(), { error: message });
|
||||
}
|
||||
});
|
||||
|
||||
test("adminErrorResponse preserves the sanitized authorization 503", async () => {
|
||||
const response = adminErrorResponse(
|
||||
new AdminAuthorizationError("后台服务暂时不可用", 503),
|
||||
|
||||
@@ -17,6 +17,12 @@ const mutationMappings = [
|
||||
api: "src/app/api/admin/products/route.ts",
|
||||
permission: "billing.products.publish",
|
||||
},
|
||||
{
|
||||
name: "商品删除",
|
||||
ui: "src/components/admin/product-management.tsx",
|
||||
api: "src/app/api/admin/products/route.ts",
|
||||
permission: "billing.products.write",
|
||||
},
|
||||
{
|
||||
name: "订阅调整",
|
||||
ui: "src/components/admin/billing-operations-resources.tsx",
|
||||
@@ -108,6 +114,10 @@ test("product management localizes product, billing and entitlement enums", () =
|
||||
}
|
||||
assert.doesNotMatch(ui, /<Tag>\{value\}<\/Tag>/);
|
||||
assert.doesNotMatch(ui, /\$\{item\.intervalCount\} \$\{item\.billingPeriod\}/);
|
||||
assert.match(ui, /action: "delete"/);
|
||||
assert.match(ui, /下架/);
|
||||
assert.match(ui, /草稿已删除/);
|
||||
assert.match(ui, /item\.status === "draft"/);
|
||||
});
|
||||
|
||||
test("model management uses ordinary admin mutation guards without reauth", () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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));
|
||||
const ids = {
|
||||
billing: "81000000-0000-4000-8000-000000000001",
|
||||
support: "81000000-0000-4000-8000-000000000002",
|
||||
};
|
||||
const publishedMonthly = "00000000-0000-4000-8000-000000000902";
|
||||
const publishedYearly = "00000000-0000-4000-8000-000000000903";
|
||||
const userPayload = JSON.stringify([
|
||||
{
|
||||
featureKey: "chat.standard",
|
||||
allowanceType: "unlimited",
|
||||
allowanceCount: null,
|
||||
resetPeriod: "billing_period",
|
||||
modelTier: "standard",
|
||||
fairUsePolicyId: "standard_monthly",
|
||||
metadata: { dayLimit: 100, minuteLimit: 6, billingLimit: 2000 },
|
||||
},
|
||||
{
|
||||
featureKey: "rectification",
|
||||
allowanceType: "quota",
|
||||
allowanceCount: 1,
|
||||
resetPeriod: "billing_period",
|
||||
modelTier: "standard",
|
||||
fairUsePolicyId: null,
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
featureKey: "report.full",
|
||||
allowanceType: "quota",
|
||||
allowanceCount: 1,
|
||||
resetPeriod: "billing_period",
|
||||
modelTier: "standard",
|
||||
fairUsePolicyId: null,
|
||||
metadata: {},
|
||||
},
|
||||
]).replaceAll("'", "''");
|
||||
|
||||
test("saving a published product forks a draft and delete retires or removes it", async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const sql = (statement: string) => fixture.psql(statement);
|
||||
const sqlAsOwner = (statement: string) => fixture.psqlAs(
|
||||
"schema_owner",
|
||||
"schema-owner-test-password",
|
||||
statement,
|
||||
);
|
||||
const expectSqlError = (statement: string, pattern: RegExp) => {
|
||||
assert.throws(() => sqlAsOwner(statement), pattern);
|
||||
};
|
||||
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`insert into identity.users (id,name,email,email_verified,email_verified_at,role) values
|
||||
('${ids.billing}','Billing','billing-product@example.com',true,now(),'admin'),
|
||||
('${ids.support}','Support','support-product@example.com',true,now(),'user')`,
|
||||
);
|
||||
sql(`
|
||||
insert into public.admin_users(user_id,created_by) values
|
||||
('${ids.billing}','${ids.billing}'),('${ids.support}','${ids.billing}');
|
||||
insert into public.admin_user_roles(admin_user_id,role_id,assigned_by)
|
||||
select v.user_id,r.id,'${ids.billing}'::uuid
|
||||
from (values ('${ids.billing}'::uuid,'billing_admin'),('${ids.support}'::uuid,'support')) v(user_id,role_code)
|
||||
join public.admin_roles r on r.code=v.role_code
|
||||
`);
|
||||
|
||||
expectSqlError(
|
||||
`select public.admin_save_product_draft('${ids.support}','${publishedMonthly}','standard_monthly','标准月卡','会员期内标准 AI 咨询不限点数,受合理使用规则约束','subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'support must not write','product-denied')`,
|
||||
/admin_permission_denied/,
|
||||
);
|
||||
|
||||
const firstDraft = sql(`
|
||||
select public.admin_save_product_draft(
|
||||
'${ids.billing}','${publishedMonthly}','standard_monthly','标准月卡','会员期内标准 AI 咨询不限点数,受合理使用规则约束',
|
||||
'subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'disable published monthly','product-save-published'
|
||||
)
|
||||
`);
|
||||
assert.notEqual(firstDraft, publishedMonthly);
|
||||
assert.equal(sql(`select status||':'||enabled||':'||version from public.billing_products where id='${publishedMonthly}'`), "published:true:1");
|
||||
assert.equal(sql(`select status||':'||enabled||':'||version||':'||code from public.billing_products where id='${firstDraft}'`), "draft:f:2:standard_monthly");
|
||||
assert.equal(sql(`select count(*) from public.product_entitlements where product_id='${firstDraft}'`), "3");
|
||||
assert.equal(sql(`select count(*) from public.billing_products where code='standard_monthly' and status='draft'`), "1");
|
||||
assert.equal(
|
||||
sql(`select after_value ? 'code' from audit.admin_audit_logs where request_id='product-save-published'`),
|
||||
"f",
|
||||
"product audit payloads must not use the forbidden code key",
|
||||
);
|
||||
|
||||
const secondSave = sql(`
|
||||
select public.admin_save_product_draft(
|
||||
'${ids.billing}','${publishedMonthly}','standard_monthly','标准月卡改名','会员期内标准 AI 咨询不限点数,受合理使用规则约束',
|
||||
'subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'update existing draft','product-save-published-again'
|
||||
)
|
||||
`);
|
||||
assert.equal(secondSave, firstDraft);
|
||||
assert.equal(sql(`select name from public.billing_products where id='${firstDraft}'`), "标准月卡改名");
|
||||
assert.equal(sql(`select count(*) from public.billing_products where code='standard_monthly' and status='draft'`), "1");
|
||||
|
||||
assert.equal(sql(`select public.admin_delete_product('${ids.billing}','${firstDraft}','remove unused draft','product-delete-draft')`), firstDraft);
|
||||
assert.equal(sql(`select count(*) from public.billing_products where id='${firstDraft}'`), "0");
|
||||
assert.equal(sql(`select count(*) from public.product_entitlements where product_id='${firstDraft}'`), "0");
|
||||
|
||||
assert.equal(sql(`select public.admin_delete_product('${ids.billing}','${publishedYearly}','take yearly off sale','product-retire-published')`), publishedYearly);
|
||||
assert.equal(sql(`select status||':'||enabled from public.billing_products where id='${publishedYearly}'`), "retired:f");
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -77,6 +77,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260814025000_personal_report_document_v2\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260814040000_personal_report_jobs_v2\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
@@ -42,7 +42,11 @@ test("existing payment orders remain queryable and settleable when subscriptions
|
||||
|
||||
test("billing and operations writes use the shared ordinary mutation guard", () => {
|
||||
assert.match(productsRoute, /requireAdminMutation\(request, permission\)/);
|
||||
assert.match(productsRoute, /admin_console_(?:save|publish)_product/);
|
||||
assert.match(productsRoute, /admin_console_(?:save|publish|delete)_product/);
|
||||
assert.match(productsRoute, /admin_delete_product/);
|
||||
const productMutationMigration = source("supabase/migrations/20260818010000_admin_product_catalog_mutations.sql");
|
||||
assert.match(productMutationMigration, /jsonb_build_object\('productCode'/);
|
||||
assert.doesNotMatch(productMutationMigration, /jsonb_build_object\('code'/);
|
||||
assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
||||
assert.match(epaySettingsRoute, /admin_save_epay_settings/);
|
||||
assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
consultationReportMarkdown,
|
||||
downloadMarkdownReport,
|
||||
} from "../src/lib/consultation-report-export.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const componentSource = readFileSync(
|
||||
new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url),
|
||||
@@ -189,6 +190,20 @@ test("global report copy uses the canonical profile, preserves accepted/confirme
|
||||
assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/);
|
||||
});
|
||||
|
||||
test("report centre and ready reader scroll inside the chat shell lock", () => {
|
||||
const centre = cssDeclarations(".report-center-shell", globalStyles);
|
||||
const reader = cssDeclarations(".personal-report-reader", globalStyles);
|
||||
assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/);
|
||||
assert.match(centre, /height:\s*100%/);
|
||||
assert.match(centre, /overflow-y:\s*auto/);
|
||||
assert.match(centre, /-webkit-overflow-scrolling:\s*touch/);
|
||||
assert.doesNotMatch(centre, /min-height:\s*100dvh/);
|
||||
assert.match(reader, /height:\s*100%/);
|
||||
assert.match(reader, /overflow-y:\s*auto/);
|
||||
assert.match(reader, /-webkit-overflow-scrolling:\s*touch/);
|
||||
assert.doesNotMatch(reader, /height:\s*100dvh/);
|
||||
});
|
||||
|
||||
test("entry is global in the sidebar and absent from the active session header", () => {
|
||||
assert.match(sidebarSource, /我的报告/);
|
||||
assert.match(sidebarSource, /onOpenReports/);
|
||||
|
||||
@@ -265,7 +265,8 @@ test("long theme sections are not forced to avoid page breaks; only small elemen
|
||||
assert.match(printBlock, /\.personal-report-avoid-break\s*\{[\s\S]*?break-inside:\s*avoid-page/);
|
||||
assert.match(printBlock, /\.personal-report-avoid-break-row\s*\{[\s\S]*?break-inside:\s*avoid/);
|
||||
assert.match(printBlock, /\.personal-report-print-always\s*\{[\s\S]*?display:\s*block\s*!important/);
|
||||
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100dvh[\s\S]*?overflow-y:\s*auto/);
|
||||
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100%[\s\S]*?overflow-y:\s*auto/);
|
||||
assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch/);
|
||||
assert.match(printBlock, /html, body\s*\{[^}]*height:\s*auto\s*!important[^}]*overflow:\s*visible\s*!important/);
|
||||
assert.match(printBlock, /\.personal-report-table-wrap\s*\{[^}]*overflow:\s*visible\s*!important/);
|
||||
assert.match(printBlock, /table-layout:\s*fixed\s*!important/);
|
||||
|
||||
Reference in New Issue
Block a user