274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { config } from "dotenv";
|
|
import { z } from "zod";
|
|
|
|
config({ path: ".env.local" });
|
|
|
|
async function main() {
|
|
const { db } = await import("@/lib/db");
|
|
const { createUpgradeRequest, approveUpgradeRequest } = await import(
|
|
"@/lib/upgrade-requests"
|
|
);
|
|
const { ACTIVATE_STATUSES, ACCOUNT_TYPES, REQUEST_STATUSES, PAID_ACCOUNT_TYPE_VALUES } = await import(
|
|
"@/lib/constants"
|
|
);
|
|
const { ApiError } = await import("@/lib/errors");
|
|
|
|
// Mirrors the zod schema in app/api/maestros/[id]/upgrade-requests/route.ts —
|
|
// this is what the [업그레이드] button's POST body must pass through.
|
|
const postBodySchema = z.object({
|
|
requestedAccountType: z.coerce
|
|
.number()
|
|
.int()
|
|
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v)),
|
|
});
|
|
|
|
type Scenario = {
|
|
label: string;
|
|
initialActivateStatus: number;
|
|
initialAccountType: number;
|
|
requestedAccountType: number;
|
|
createExpectsError?: number;
|
|
approveExpectsError?: number;
|
|
directInsertRequest?: boolean;
|
|
};
|
|
|
|
const scenarios: Scenario[] = [
|
|
{
|
|
label: "TRIAL status + paid AccountType(BASIC_20) → PRO_100 (higher tier)",
|
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
|
initialAccountType: ACCOUNT_TYPES.BASIC_20,
|
|
requestedAccountType: ACCOUNT_TYPES.PRO_100,
|
|
},
|
|
{
|
|
label: "TRIAL status + paid AccountType(STANDARD_50) → STANDARD_50 (same-tier conversion)",
|
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
|
initialAccountType: ACCOUNT_TYPES.STANDARD_50,
|
|
requestedAccountType: ACCOUNT_TYPES.STANDARD_50,
|
|
},
|
|
{
|
|
label: "TRIAL status + trial AccountType(MAESTRO_TRIAL=101) → BASIC_20 (via admin create)",
|
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
|
initialAccountType: ACCOUNT_TYPES.MAESTRO_TRIAL,
|
|
requestedAccountType: ACCOUNT_TYPES.BASIC_20,
|
|
},
|
|
{
|
|
label: "TRIAL status + trial AccountType(STUDENT_TRIAL=100) → SCHOOL_1000 (direct-insert bypass, mimicking chocomae flow)",
|
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
|
initialAccountType: ACCOUNT_TYPES.STUDENT_TRIAL,
|
|
requestedAccountType: ACCOUNT_TYPES.SCHOOL_1000,
|
|
directInsertRequest: true,
|
|
},
|
|
];
|
|
|
|
let failures = 0;
|
|
|
|
for (const scenario of scenarios) {
|
|
const stamp = Date.now();
|
|
const testName = `__TEST_TRIAL_UPG_${stamp}__`.slice(0, 50);
|
|
const initialAvailable = new Date(2000, 0, 1);
|
|
let maestroID: number | null = null;
|
|
|
|
console.log(`\n=== ${scenario.label} ===`);
|
|
|
|
try {
|
|
const created = await db.maestro.create({
|
|
data: {
|
|
Name: testName,
|
|
Password: "",
|
|
Email: `${stamp}@test.invalid`.slice(0, 50),
|
|
AccountType: scenario.initialAccountType,
|
|
ActivateStatus: scenario.initialActivateStatus,
|
|
AvailableActivateDateTime: initialAvailable,
|
|
PlayerCount: 0,
|
|
AcceptClausesDateTime: new Date(),
|
|
AllowEditEnterCode: 0,
|
|
},
|
|
select: { MaestroID: true },
|
|
});
|
|
maestroID = created.MaestroID;
|
|
console.log(` seeded maestro MaestroID=${maestroID}`);
|
|
|
|
let maestroUpgradeID: number | null = null;
|
|
|
|
// Step 1: create request (either via admin API or direct insert)
|
|
if (scenario.directInsertRequest) {
|
|
const inserted = await db.maestro_upgrade.create({
|
|
data: {
|
|
MaestroID: maestroID,
|
|
RegisteredActivateStatus: scenario.initialActivateStatus,
|
|
RegisteredAccountType: scenario.initialAccountType,
|
|
RequestedAccountType: scenario.requestedAccountType,
|
|
RequestedDateTime: new Date(),
|
|
Status: REQUEST_STATUSES.REQUESTED,
|
|
},
|
|
select: { MaestroUpgradeID: true },
|
|
});
|
|
maestroUpgradeID = inserted.MaestroUpgradeID;
|
|
console.log(` direct-inserted upgrade request maestroUpgradeID=${maestroUpgradeID}`);
|
|
} else {
|
|
// Simulate the [업그레이드] button POST body path: zod-validate first.
|
|
const parsed = postBodySchema.safeParse({
|
|
requestedAccountType: scenario.requestedAccountType,
|
|
});
|
|
console.log(
|
|
` zod schema on { requestedAccountType: ${scenario.requestedAccountType} } → ${parsed.success ? "accepted" : "rejected"}`
|
|
);
|
|
if (!parsed.success) {
|
|
console.log(
|
|
` FAIL API zod schema rejected paid-tier target (would produce 400 from API layer)`
|
|
);
|
|
failures += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const result = await createUpgradeRequest(
|
|
maestroID,
|
|
parsed.data.requestedAccountType
|
|
);
|
|
if (scenario.createExpectsError !== undefined) {
|
|
console.log(` FAIL createUpgradeRequest expected ApiError(${scenario.createExpectsError}), succeeded`);
|
|
failures += 1;
|
|
} else {
|
|
maestroUpgradeID = result.maestroUpgradeID;
|
|
console.log(` createUpgradeRequest → maestroUpgradeID=${maestroUpgradeID}, status=${result.status}`);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
scenario.createExpectsError !== undefined &&
|
|
error instanceof ApiError &&
|
|
error.statusCode === scenario.createExpectsError
|
|
) {
|
|
console.log(` PASS createUpgradeRequest correctly threw ApiError(${error.statusCode}): ${error.message}`);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (maestroUpgradeID === null) continue;
|
|
|
|
// Step 2: approve
|
|
try {
|
|
const approveResult = await approveUpgradeRequest(maestroUpgradeID);
|
|
if (scenario.approveExpectsError !== undefined) {
|
|
console.log(` FAIL approveUpgradeRequest expected ApiError(${scenario.approveExpectsError}), succeeded`);
|
|
failures += 1;
|
|
continue;
|
|
}
|
|
console.log(` approveUpgradeRequest → availableActivateDateTime=${approveResult.availableActivateDateTime}`);
|
|
} catch (error) {
|
|
if (
|
|
scenario.approveExpectsError !== undefined &&
|
|
error instanceof ApiError &&
|
|
error.statusCode === scenario.approveExpectsError
|
|
) {
|
|
console.log(` PASS approveUpgradeRequest correctly threw ApiError(${error.statusCode}): ${error.message}`);
|
|
|
|
// Verify rollback: request row should NOT be APPLIED, and maestro state should be unchanged
|
|
const stillRequestedRow = await db.maestro_upgrade.findUniqueOrThrow({
|
|
where: { MaestroUpgradeID: maestroUpgradeID },
|
|
select: { Status: true },
|
|
});
|
|
const stillTrialMaestro = await db.maestro.findUniqueOrThrow({
|
|
where: { MaestroID: maestroID },
|
|
select: { ActivateStatus: true, AccountType: true },
|
|
});
|
|
const rollbackOK =
|
|
stillRequestedRow.Status === REQUEST_STATUSES.REQUESTED &&
|
|
stillTrialMaestro.ActivateStatus === scenario.initialActivateStatus &&
|
|
stillTrialMaestro.AccountType === scenario.initialAccountType;
|
|
console.log(` ${rollbackOK ? "PASS" : "FAIL"} transaction rollback preserved state`);
|
|
if (!rollbackOK) failures += 1;
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Step 3: verify post-approval state
|
|
const afterApprove = await db.maestro.findUniqueOrThrow({
|
|
where: { MaestroID: maestroID },
|
|
select: {
|
|
ActivateStatus: true,
|
|
AvailableActivateDateTime: true,
|
|
AccountType: true,
|
|
PlayerCount: true,
|
|
},
|
|
});
|
|
const requestRow = await db.maestro_upgrade.findUniqueOrThrow({
|
|
where: { MaestroUpgradeID: maestroUpgradeID },
|
|
select: { Status: true },
|
|
});
|
|
const logRows = await db.maestro_log.findMany({
|
|
where: { MaestroID: maestroID },
|
|
select: { Type: true },
|
|
orderBy: { MaestroLogID: "asc" },
|
|
});
|
|
|
|
const nowPlus1yr = new Date();
|
|
nowPlus1yr.setFullYear(nowPlus1yr.getFullYear() + 1);
|
|
|
|
const checks: Array<[string, boolean, string]> = [
|
|
[
|
|
"maestro.ActivateStatus becomes ACTIVE",
|
|
afterApprove.ActivateStatus === ACTIVATE_STATUSES.ACTIVE,
|
|
`got ${afterApprove.ActivateStatus}`,
|
|
],
|
|
[
|
|
"maestro.AccountType becomes RequestedAccountType",
|
|
afterApprove.AccountType === scenario.requestedAccountType,
|
|
`got ${afterApprove.AccountType}, expected ${scenario.requestedAccountType}`,
|
|
],
|
|
[
|
|
"maestro.PlayerCount unchanged (upgrade should NOT touch PlayerCount)",
|
|
afterApprove.PlayerCount === 0,
|
|
`got ${afterApprove.PlayerCount}`,
|
|
],
|
|
[
|
|
"AvailableActivateDateTime ≈ now + 1 year",
|
|
Math.abs(afterApprove.AvailableActivateDateTime.getTime() - nowPlus1yr.getTime()) < 60_000,
|
|
`delta=${afterApprove.AvailableActivateDateTime.getTime() - nowPlus1yr.getTime()}ms`,
|
|
],
|
|
[
|
|
"upgrade request Status becomes APPLIED",
|
|
requestRow.Status === REQUEST_STATUSES.APPLIED,
|
|
`got ${requestRow.Status}`,
|
|
],
|
|
[
|
|
"logs include upgrade_maestro",
|
|
logRows.some((l) => l.Type === "upgrade_maestro"),
|
|
JSON.stringify(logRows.map((l) => l.Type)),
|
|
],
|
|
];
|
|
|
|
for (const [name, passed, detail] of checks) {
|
|
console.log(` ${passed ? "PASS" : "FAIL"} ${name} (${detail})`);
|
|
if (!passed) failures += 1;
|
|
}
|
|
} catch (error) {
|
|
failures += 1;
|
|
console.error(` ERROR:`, error);
|
|
} finally {
|
|
if (maestroID !== null) {
|
|
await db.maestro_upgrade.deleteMany({ where: { MaestroID: maestroID } });
|
|
await db.maestro_log.deleteMany({ where: { MaestroID: maestroID } });
|
|
await db.maestro.delete({ where: { MaestroID: maestroID } });
|
|
console.log(` cleanup: removed rows for MaestroID=${maestroID}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await db.$disconnect();
|
|
|
|
if (failures > 0) {
|
|
console.error(`\n${failures} check(s) failed.`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`\nAll checks passed.`);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Test run failed");
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
}); |