feat: add dispatch retry and import recovery flows

This commit is contained in:
kris
2026-03-31 22:10:03 +08:00
parent be31503d22
commit dcbff3cc7d
15 changed files with 776 additions and 23 deletions

View File

@@ -426,6 +426,118 @@ test("device import apply is idempotent and heartbeat preserves applied status",
assert.equal(appliedDraft?.status, "applied", "later heartbeats should not regress applied drafts");
});
test("clearing device import selection resets draft back to pending_selection and drops old resolution", async () => {
await setup();
const enrollmentResponse = await createEnrollmentRoute(
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
name: "Review Mac",
avatar: "R",
account: "17600003315",
endpoint: "mac://review.local",
note: "selection reset",
}),
);
assert.equal(enrollmentResponse.status, 200);
const enrollmentPayload = (await enrollmentResponse.json()) as {
enrollment: { pairingCode: string };
device: { id: string };
};
assert.equal(
(
await deviceHeartbeatRoute(
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
deviceId: enrollmentPayload.device.id,
pairingCode: enrollmentPayload.enrollment.pairingCode,
name: "Review Mac",
avatar: "R",
account: "17600003315",
status: "online",
quota5h: 66,
quota7d: 79,
projects: [],
endpoint: "mac://review.local",
projectCandidates: [
{
folderName: "回归目录",
folderRef: "review-folder",
threadId: "thread-review-1",
threadDisplayName: "回归线程一",
codexFolderRef: "review-folder",
codexThreadRef: "thread-review-1",
lastActiveAt: "2026-03-30T11:00:00+08:00",
suggestedImport: true,
},
],
}),
}),
)
).status,
200,
);
const draftResponse = await getImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
"GET",
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
);
const draftPayload = (await draftResponse.json()) as {
draft: { candidates: Array<{ candidateId: string }> };
};
const selectedCandidateIds = draftPayload.draft.candidates.map((candidate) => candidate.candidateId);
assert.equal(
(
await selectImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
"POST",
{ selectedCandidateIds },
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
)
).status,
200,
);
assert.equal(
(
await reviewImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
"POST",
{},
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
)
).status,
200,
);
const clearSelectionResponse = await selectImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
"POST",
{ selectedCandidateIds: [] },
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
);
assert.equal(clearSelectionResponse.status, 200);
const nextState = await readState();
const draft = nextState.deviceImportDrafts.find((item) => item.deviceId === enrollmentPayload.device.id);
const resolution = nextState.deviceImportResolutions.find((item) => item.deviceId === enrollmentPayload.device.id);
assert.equal(draft?.status, "pending_selection");
assert.deepEqual(draft?.selectedCandidateIds, []);
assert.equal(draft?.resolutionId, undefined);
assert.equal(resolution, undefined);
});
test("device import routes reject unrelated logged-in members", async () => {
await setup();