fix(housekeeping): restore people partial compatibility
This commit is contained in:
1 parent
fcd1dfd96e
commit
a6a288d9ff
6 files changed
+412
-11
No files matched your search
@@ -54,3 +54,46 @@ it("keeps one ACL check while delegating a positive bulk adjustment", async () =
|
||||
{ userIds: [7, 8], amount: 25, type: "credits" },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the pre-Task12 positive-adjust result when currency RCON throws after the database commit", async () => {
|
||||
execute.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { completed: 0, total: 1, failedIds: [] },
|
||||
after: {
|
||||
completed: 1,
|
||||
total: 1,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 7,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
],
|
||||
},
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
},
|
||||
correlationId: "legacy-positive-adjust",
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
bulkAdjustCurrency({ userIds: [7], amount: 25, type: "credits" }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
adjusted: 0,
|
||||
total: 1,
|
||||
failedIds: [{ userId: 7, reason: "Database error" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -91,4 +91,64 @@ describe("legacy bulk user wrappers", () => {
|
||||
{ userId: 9, untilUnix: 1234 },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"currency",
|
||||
"users.bulk-currency",
|
||||
() => bulkGiveCurrency({ userIds: [7], amount: 100, type: "credits" }),
|
||||
],
|
||||
[
|
||||
"badge",
|
||||
"users.bulk-badge",
|
||||
() => bulkGiveBadge({ userIds: [7], badgeCode: "ADM" }),
|
||||
],
|
||||
] as const)(
|
||||
"restores the pre-Task12 legacy result when %s RCON throws after the database commit",
|
||||
async (_kind, operation, invoke) => {
|
||||
execute.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { completed: 0, total: 1, failedIds: [] },
|
||||
after: {
|
||||
completed: 1,
|
||||
total: 1,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 7,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
],
|
||||
},
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
},
|
||||
correlationId: "legacy-external-sync-failure",
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(invoke()).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
given: 0,
|
||||
total: 1,
|
||||
failedIds: [{ userId: 7, reason: "Database error" }],
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
operation,
|
||||
expect.anything(),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -52,6 +52,35 @@ function failedIds(value: unknown): Array<{ userId: number; reason: string }> {
|
||||
: [];
|
||||
}
|
||||
|
||||
function legacyBulkOutcome(
|
||||
after: Readonly<Record<string, unknown>> | null,
|
||||
userIds: readonly number[],
|
||||
): {
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly failedIds: Array<{ userId: number; reason: string }>;
|
||||
} {
|
||||
const databaseFailures = failedIds(after?.failedIds);
|
||||
const externalSyncFailures = failedIds(after?.externalSyncFailures).map(
|
||||
({ userId }) => ({ userId, reason: "Database error" }),
|
||||
);
|
||||
const pendingFailures = [...databaseFailures, ...externalSyncFailures];
|
||||
const orderedFailures = userIds.flatMap((userId) => {
|
||||
const index = pendingFailures.findIndex(
|
||||
(failure) => failure.userId === userId,
|
||||
);
|
||||
return index === -1 ? [] : pendingFailures.splice(index, 1);
|
||||
});
|
||||
return {
|
||||
completed: Math.max(
|
||||
0,
|
||||
numberValue(after?.completed) - externalSyncFailures.length,
|
||||
),
|
||||
total: numberValue(after?.total),
|
||||
failedIds: [...orderedFailures, ...pendingFailures],
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
}: {
|
||||
@@ -113,12 +142,13 @@ export async function bulkGiveCurrency({
|
||||
type,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Bulk currency failed" };
|
||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
given: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
given: outcome.completed,
|
||||
total: outcome.total,
|
||||
failedIds: outcome.failedIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -142,12 +172,13 @@ export async function bulkGiveBadge({
|
||||
badgeCode,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Bulk badge failed" };
|
||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
given: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
given: outcome.completed,
|
||||
total: outcome.total,
|
||||
failedIds: outcome.failedIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -178,12 +209,13 @@ export async function bulkAdjustCurrency({
|
||||
type,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Currency adjustment failed" };
|
||||
const outcome = legacyBulkOutcome(result.data.after, userIds);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
adjusted: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
adjusted: outcome.completed,
|
||||
total: outcome.total,
|
||||
failedIds: outcome.failedIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ function parseField(field: PeopleCommandField, formData: FormData): unknown {
|
||||
}
|
||||
|
||||
const initialState: HousekeepingResult<unknown> | null = null;
|
||||
const externalSyncFailureReason =
|
||||
"Database applied; emulator sync failed. Do not retry automatically.";
|
||||
|
||||
interface ExternalSyncFailure {
|
||||
readonly key: string;
|
||||
readonly userId: number;
|
||||
readonly reason: typeof externalSyncFailureReason;
|
||||
}
|
||||
|
||||
export async function submitPeopleCommandForm(
|
||||
configuration: PeopleCommandSubmission,
|
||||
@@ -96,6 +104,7 @@ export function PeopleCommandResult({
|
||||
readonly result: HousekeepingResult<unknown>;
|
||||
}) {
|
||||
const generatedPassword = readGeneratedPassword(result);
|
||||
const externalSyncFailures = readExternalSyncFailures(result);
|
||||
const message = result.ok
|
||||
? result.completion?.status === "partial"
|
||||
? `Partially completed (${result.correlationId})`
|
||||
@@ -103,10 +112,28 @@ export function PeopleCommandResult({
|
||||
: `Failed: ${result.error.messageKey} (${result.correlationId})`;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
role={externalSyncFailures.length > 0 ? "alert" : "status"}
|
||||
className="space-y-1 text-xs text-[var(--admin-text-muted)]"
|
||||
>
|
||||
<p>{message}</p>
|
||||
{externalSyncFailures.length > 0 ? (
|
||||
<>
|
||||
<p>
|
||||
<strong>Do not retry automatically.</strong> The database updates
|
||||
were applied; retrying may duplicate them.
|
||||
</p>
|
||||
<ul
|
||||
aria-label="External synchronization debt"
|
||||
className="list-disc space-y-1 pl-5"
|
||||
>
|
||||
{externalSyncFailures.map((failure) => (
|
||||
<li key={failure.key}>
|
||||
User #{failure.userId}: {failure.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{generatedPassword ? (
|
||||
<p>
|
||||
Generated temporary password:{" "}
|
||||
@@ -119,6 +146,42 @@ export function PeopleCommandResult({
|
||||
);
|
||||
}
|
||||
|
||||
function readExternalSyncFailures(
|
||||
result: HousekeepingResult<unknown>,
|
||||
): readonly ExternalSyncFailure[] {
|
||||
if (
|
||||
!result.ok ||
|
||||
result.completion?.status !== "partial" ||
|
||||
result.completion.external !== "failed" ||
|
||||
!isRecord(result.data) ||
|
||||
!isRecord(result.data.after) ||
|
||||
!Array.isArray(result.data.after.externalSyncFailures)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const occurrences = new Map<number, number>();
|
||||
const failures: ExternalSyncFailure[] = [];
|
||||
for (const value of result.data.after.externalSyncFailures.slice(0, 100)) {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.userId !== "number" ||
|
||||
!Number.isSafeInteger(value.userId) ||
|
||||
value.userId <= 0 ||
|
||||
value.reason !== externalSyncFailureReason
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const occurrence = (occurrences.get(value.userId) ?? 0) + 1;
|
||||
occurrences.set(value.userId, occurrence);
|
||||
failures.push({
|
||||
key: `${value.userId}:${occurrence}`,
|
||||
userId: value.userId,
|
||||
reason: externalSyncFailureReason,
|
||||
});
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function readGeneratedPassword(
|
||||
result: HousekeepingResult<unknown>,
|
||||
): string | null {
|
||||
|
||||
@@ -401,6 +401,115 @@ describe("People actionable form contract", () => {
|
||||
expect(html.match(new RegExp(password, "gu"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders typed external sync debt with an explicit do-not-retry warning", () => {
|
||||
const completion = {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
} as const;
|
||||
const html = renderToStaticMarkup(
|
||||
<PeopleCommandResult
|
||||
result={ok(
|
||||
{
|
||||
before: { completed: 0, total: 2, failedIds: [] },
|
||||
after: {
|
||||
completed: 2,
|
||||
total: 2,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 7,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
{
|
||||
userId: "8",
|
||||
reason: "unsafe payload from an untyped result",
|
||||
},
|
||||
],
|
||||
debug: "sensitive internal payload",
|
||||
},
|
||||
completion,
|
||||
},
|
||||
"bulk-sync-debt",
|
||||
completion,
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Do not retry automatically.");
|
||||
expect(html).toContain('aria-label="External synchronization debt"');
|
||||
expect(html).toContain("User #7");
|
||||
expect(html).toContain("Database applied; emulator sync failed.");
|
||||
expect(html).not.toContain("unsafe payload from an untyped result");
|
||||
expect(html).not.toContain("sensitive internal payload");
|
||||
});
|
||||
|
||||
it("keeps form-returned external sync debt visible in the operator result surface", async () => {
|
||||
const completion = {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
} as const;
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok(
|
||||
{
|
||||
before: { completed: 0, total: 1, failedIds: [] },
|
||||
after: {
|
||||
completed: 1,
|
||||
total: 1,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 23,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
],
|
||||
},
|
||||
completion,
|
||||
},
|
||||
"form-sync-debt",
|
||||
completion,
|
||||
),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("userIds", "23");
|
||||
formData.set("amount", "100");
|
||||
formData.set("type", "credits");
|
||||
formData.set("reason", "Planned grant");
|
||||
|
||||
const result = await submitPeopleCommandForm(
|
||||
{
|
||||
commandId: "people.users.bulk-currency",
|
||||
input: {},
|
||||
fields: [
|
||||
{ name: "userIds", label: "User IDs", type: "number-list" },
|
||||
{ name: "amount", label: "Amount", type: "number", min: 1 },
|
||||
{
|
||||
name: "type",
|
||||
label: "Currency",
|
||||
type: "select",
|
||||
options: [{ value: "credits", label: "Credits" }],
|
||||
},
|
||||
],
|
||||
requiresReason: true,
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
const html = renderToStaticMarkup(<PeopleCommandResult result={result} />);
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "people.users.bulk-currency",
|
||||
input: { userIds: [23], amount: 100, type: "credits" },
|
||||
reason: "Planned grant",
|
||||
});
|
||||
expect(html).toContain("Do not retry automatically.");
|
||||
expect(html).toContain("User #23");
|
||||
});
|
||||
|
||||
it("provides a real Next loading boundary", () => {
|
||||
const source = readFileSync(
|
||||
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
|
||||
|
||||
Reference in new issue
Block a user