Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/jobs/apple-health-import-worker.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 21:48:20 +02:00
commit 4409529c89
74 files changed
+4022 -307

No files matched your search

@@ -27,6 +27,7 @@ vi.mock("next/headers", () => ({
}));
const mocks = vi.hoisted(() => ({
runningImportFindFirst: vi.fn(async () => null),
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
@@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => ({
importJobUpdate: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -51,7 +53,7 @@ vi.mock("@/lib/multipart/stream-to-disk", () => ({
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, unlink: mocks.unlink };
return { ...actual, unlink: mocks.unlink, rm: mocks.rm };
});
vi.mock("@/lib/db", () => ({
@@ -61,6 +63,16 @@ vi.mock("@/lib/db", () => ({
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
// The one-running-import check and the new row, in one transaction.
// The check finds nothing running unless a test says otherwise.
$transaction: async (fn: (tx: unknown) => unknown) =>
fn({
$executeRaw: async () => 0,
importJob: {
findFirst: mocks.runningImportFindFirst,
create: mocks.importJobCreate,
},
}),
},
}));
@@ -90,6 +102,7 @@ beforeEach(() => {
textFields: {},
});
mocks.unlink.mockResolvedValue(undefined);
mocks.rm.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
@@ -220,3 +233,51 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
);
});
});
// The staged upload is the person's export in plain text. Every exit that
// leaves no worker to consume it removes it, and a second import for the same
// account is refused while one is running.
describe("POST /api/import/apple-health-export — the staged upload on every exit", () => {
const removed = () =>
mocks.rm.mock.calls.some(([path]) => path === STAGED_PATH);
it("refuses a second import while one is running, and removes the upload", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.runningImportFindFirst.mockResolvedValue({
id: "ij-running",
status: "parsing",
} as never);
const res = await POST(multipartReq());
expect(res.status).toBe(409);
const body = await res.json();
expect(body.meta).toMatchObject({
errorCode: "import.apple_health.busy",
jobId: "ij-running",
});
expect(mocks.importJobCreate).not.toHaveBeenCalled();
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(removed()).toBe(true);
});
it("removes the upload when no worker is running", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.getGlobalBoss.mockReturnValue(null);
const res = await POST(multipartReq());
expect(res.status).toBe(503);
expect(removed()).toBe(true);
});
it("fails the row and removes the upload when the queue takes no job", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.bossSend.mockResolvedValue(null);
const res = await POST(multipartReq());
expect(res.status).toBe(503);
expect(mocks.importJobUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ij-1" },
data: expect.objectContaining({ status: "failed" }),
}),
);
expect(removed()).toBe(true);
});
});
@@ -35,6 +35,11 @@ import {
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import {
createImportJobUnlessBusy,
discardStagedUpload,
IMPORT_BUSY_CODE,
} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -147,18 +152,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
// Nothing will ever read the staged export; it must not stay in /tmp.
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
// One running import per account (see `apple-health-staging.ts`).
const staged = await createImportJobUnlessBusy(prisma, {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
});
if ("busy" in staged) {
await discardStagedUpload(uploaded.filePath);
return apiError(
"An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
409,
{ errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
);
}
const importJob = staged.created;
const payload: AppleHealthImportPayload = {
userId: user.id,
@@ -174,6 +189,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) {
// The queue did not take the job, so no worker will consume the upload
// or move the row on. Fail the row now and remove the export.
await prisma.importJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason:
"The import could not be queued. Upload the export again.",
completedAt: new Date(),
},
});
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "The import could not be queued");
}
await prisma.importJob.update({
where: { id: importJob.id },