Skip to content

Commit 055d289

Browse files
committed
fix: recover drained legacy edits from the preserved original
1 parent dea829c commit 055d289

8 files changed

Lines changed: 535 additions & 23 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const list = vi.hoisted(() => vi.fn());
4+
vi.mock("workflow/runtime", () => ({
5+
getWorld: () => ({ runs: { list } }),
6+
}));
7+
8+
import { assertLegacyEditsQuiescent } from "@/lib/legacy-video-edit-recovery";
9+
10+
beforeEach(() => {
11+
list.mockReset().mockResolvedValue({ data: [] });
12+
vi.stubEnv("CAP_LEGACY_EDIT_RECOVERY", "enabled");
13+
});
14+
afterEach(() => vi.unstubAllEnvs());
15+
16+
describe("legacy edit recovery rollout", () => {
17+
it("refuses recovery until older deployments and workers have been drained", async () => {
18+
vi.stubEnv("CAP_LEGACY_EDIT_RECOVERY", "");
19+
await expect(assertLegacyEditsQuiescent("edit-workflow")).rejects.toThrow(
20+
"support recovery",
21+
);
22+
expect(list).not.toHaveBeenCalled();
23+
});
24+
it.each(["pending", "running"])(
25+
"refuses recovery while an edit is %s",
26+
async (status) => {
27+
list.mockImplementation(async (query: { status: string }) => ({
28+
data: query.status === status ? [{ runId: "active" }] : [],
29+
}));
30+
await expect(assertLegacyEditsQuiescent("edit-workflow")).rejects.toThrow(
31+
"still finishing",
32+
);
33+
},
34+
);
35+
it("fails closed when workflow state is unavailable", async () => {
36+
list.mockRejectedValue(new Error("Workflow service unavailable"));
37+
await expect(assertLegacyEditsQuiescent("edit-workflow")).rejects.toThrow(
38+
"Workflow service unavailable",
39+
);
40+
});
41+
it("allows recovery after the explicit drain and runtime checks", async () => {
42+
await expect(
43+
assertLegacyEditsQuiescent("edit-workflow"),
44+
).resolves.toBeUndefined();
45+
expect(list).toHaveBeenCalledWith(
46+
expect.objectContaining({
47+
workflowName: "edit-workflow",
48+
status: "pending",
49+
resolveData: "none",
50+
}),
51+
);
52+
expect(list).toHaveBeenCalledWith(
53+
expect.objectContaining({
54+
workflowName: "edit-workflow",
55+
status: "running",
56+
}),
57+
);
58+
});
59+
});
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { User, Video } from "@cap/web-domain";
2+
import { Effect } from "effect";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const mocks = vi.hoisted(() => ({
6+
video: {} as Record<string, unknown>,
7+
upload: undefined as Record<string, unknown> | undefined,
8+
edit: undefined as Record<string, unknown> | undefined,
9+
writes: [] as string[],
10+
auth: vi.fn(),
11+
start: vi.fn(),
12+
quiescent: vi.fn(),
13+
head: vi.fn(),
14+
copy: vi.fn(),
15+
access: vi.fn(),
16+
fetch: vi.fn(),
17+
clear: vi.fn(),
18+
}));
19+
vi.mock("@cap/database/schema", () => ({
20+
videos: { name: "video", id: "id" },
21+
videoUploads: { name: "upload", videoId: "videoId" },
22+
videoEdits: { name: "edit", videoId: "videoId" },
23+
}));
24+
vi.mock("@cap/database", () => {
25+
const client = {
26+
select: () => ({
27+
from: (table: { name: "video" | "upload" | "edit" }) => ({
28+
where: () => {
29+
const rows = mocks[table.name]
30+
? [structuredClone(mocks[table.name])]
31+
: [];
32+
return Object.assign(Promise.resolve(rows), {
33+
for: async () => rows,
34+
});
35+
},
36+
}),
37+
}),
38+
insert: (table: { name: string }) => ({
39+
values: async (data: Record<string, unknown>) => {
40+
mocks.writes.push(`insert-${table.name}`);
41+
mocks.upload = data;
42+
},
43+
}),
44+
delete: (table: { name: string }) => ({
45+
where: async () => {
46+
mocks.writes.push(`delete-${table.name}`);
47+
mocks.upload = undefined;
48+
},
49+
}),
50+
update: (table: { name: string }) => ({
51+
set: (data: Record<string, unknown>) => ({
52+
where: async () => {
53+
mocks.writes.push(`update-${table.name}`);
54+
Object.assign(mocks.video, data);
55+
},
56+
}),
57+
}),
58+
};
59+
return {
60+
db: () => ({
61+
...client,
62+
transaction: async (run: (tx: typeof client) => Promise<unknown>) => {
63+
const snapshot = structuredClone({
64+
video: mocks.video,
65+
upload: mocks.upload,
66+
writes: mocks.writes,
67+
});
68+
try {
69+
return await run(client);
70+
} catch (error) {
71+
Object.assign(mocks, snapshot);
72+
throw error;
73+
}
74+
},
75+
}),
76+
};
77+
});
78+
vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.auth }));
79+
vi.mock("@cap/utils", () => ({
80+
userIsPro: (user: { isPro: boolean }) => user.isPro,
81+
}));
82+
vi.mock("@cap/env", () => ({
83+
serverEnv: () => ({ MEDIA_SERVER_URL: "https://media.test" }),
84+
}));
85+
vi.mock("@cap/web-backend", () => ({
86+
Storage: { getAccessForVideo: mocks.access },
87+
}));
88+
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
89+
vi.mock("workflow/api", () => ({ start: mocks.start }));
90+
vi.mock("@/workflows/edit-video", () => ({
91+
editVideoWorkflow: Object.assign(vi.fn(), { workflowId: "edit-workflow" }),
92+
}));
93+
vi.mock("@/lib/legacy-video-edit-recovery", () => ({
94+
assertLegacyEditsQuiescent: mocks.quiescent,
95+
}));
96+
vi.mock("@/lib/server", async () => ({
97+
runPromise: (await import("effect")).Effect.runPromise,
98+
}));
99+
vi.mock("@/lib/video-storage", () => ({
100+
decodeStorageVideo: (video: unknown) => video,
101+
}));
102+
vi.mock("@/lib/video-edit-operation", () => ({
103+
clearPendingEdit: mocks.clear,
104+
}));
105+
vi.mock("@/utils/flags", () => ({ isAiGenerationEnabled: async () => false }));
106+
107+
import {
108+
restoreVideoToOriginal,
109+
saveVideoEdits,
110+
} from "@/actions/videos/save-edits";
111+
112+
const videoId = Video.VideoId.make("video");
113+
const sourceKey = "owner/video/source/original.mp4";
114+
const trim = {
115+
version: 1 as const,
116+
sourceDuration: 10,
117+
keepRanges: [{ start: 0, end: 5 }],
118+
};
119+
120+
beforeEach(() => {
121+
vi.resetAllMocks();
122+
mocks.video = {
123+
id: videoId,
124+
ownerId: User.UserId.make("owner"),
125+
bucket: null,
126+
storageIntegrationId: null,
127+
source: { type: "webMP4" },
128+
duration: 10,
129+
metadata: null,
130+
};
131+
mocks.upload = undefined;
132+
mocks.edit = undefined;
133+
mocks.writes = [];
134+
mocks.auth.mockResolvedValue({ id: "owner", isPro: true });
135+
mocks.head.mockReturnValue(Effect.succeed({}));
136+
mocks.copy.mockReturnValue(Effect.void);
137+
mocks.access.mockReturnValue(
138+
Effect.succeed([
139+
{
140+
bucketName: "bucket",
141+
headObject: mocks.head,
142+
copyObject: mocks.copy,
143+
getInternalSignedObjectUrl: () =>
144+
Effect.succeed("https://storage.test/original.mp4"),
145+
},
146+
]),
147+
);
148+
mocks.fetch.mockResolvedValue(
149+
new Response(JSON.stringify({ metadata: { duration: 20 } }), {
150+
status: 200,
151+
}),
152+
);
153+
vi.stubGlobal("fetch", mocks.fetch);
154+
});
155+
afterEach(() => vi.unstubAllGlobals());
156+
157+
function legacyUpload() {
158+
mocks.upload = {
159+
videoId,
160+
rawFileKey: sourceKey,
161+
phase: "processing",
162+
startedAt: new Date("2026-09-01T00:00:00Z"),
163+
updatedAt: new Date("2026-09-01T01:00:00Z"),
164+
};
165+
}
166+
167+
describe("video edit claims and recovery", () => {
168+
it("claims the recording before copying its original source", async () => {
169+
mocks.head.mockReturnValue(Effect.fail(new Error("Missing")));
170+
mocks.copy.mockImplementation(() => {
171+
expect(mocks.upload?.phase).toBe("processing");
172+
return Effect.void;
173+
});
174+
await saveVideoEdits(videoId, trim);
175+
expect(mocks.copy).toHaveBeenCalledOnce();
176+
expect(mocks.start).toHaveBeenCalledOnce();
177+
});
178+
it("releases a pending claim when the workflow start fails", async () => {
179+
mocks.start.mockRejectedValue(new Error("Enqueue failed"));
180+
await expect(saveVideoEdits(videoId, trim)).rejects.toThrow(
181+
"Enqueue failed",
182+
);
183+
expect(mocks.clear).toHaveBeenCalledWith(
184+
videoId,
185+
sourceKey,
186+
expect.objectContaining({ token: expect.any(String) }),
187+
);
188+
});
189+
it("preserves a legacy upload when recovery checks fail", async () => {
190+
legacyUpload();
191+
const upload = structuredClone(mocks.upload);
192+
mocks.quiescent.mockRejectedValue(new Error("Still running"));
193+
await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(
194+
"Still running",
195+
);
196+
expect(mocks.upload).toEqual(upload);
197+
expect(mocks.writes).toEqual([]);
198+
expect(mocks.fetch).not.toHaveBeenCalled();
199+
});
200+
it("restores an interrupted first edit using the original duration even without an edit record", async () => {
201+
legacyUpload();
202+
await restoreVideoToOriginal(videoId);
203+
expect(mocks.quiescent).toHaveBeenCalledWith("edit-workflow");
204+
expect(mocks.writes).toEqual([
205+
"delete-upload",
206+
"insert-upload",
207+
"update-video",
208+
]);
209+
expect(mocks.start).toHaveBeenCalledWith(expect.any(Function), [
210+
expect.objectContaining({
211+
sourceKey,
212+
editSpec: {
213+
version: 1,
214+
sourceDuration: 20,
215+
keepRanges: [{ start: 0, end: 20 }],
216+
},
217+
}),
218+
]);
219+
expect(mocks.copy).not.toHaveBeenCalled();
220+
});
221+
it("refuses to replace a legacy row that changed during original verification", async () => {
222+
legacyUpload();
223+
mocks.fetch.mockImplementation(async () => {
224+
if (mocks.upload)
225+
mocks.upload.updatedAt = new Date("2026-09-08T00:00:00Z");
226+
return new Response(JSON.stringify({ metadata: { duration: 20 } }), {
227+
status: 200,
228+
});
229+
});
230+
await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(
231+
"already uploading",
232+
);
233+
expect(mocks.writes).toEqual([]);
234+
expect(mocks.start).not.toHaveBeenCalled();
235+
});
236+
it("preserves recovery state when the original cannot be verified", async () => {
237+
legacyUpload();
238+
mocks.fetch.mockResolvedValue(
239+
new Response(JSON.stringify({ metadata: { duration: 0 } }), {
240+
status: 200,
241+
}),
242+
);
243+
await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(
244+
"could not be verified",
245+
);
246+
expect(mocks.writes).toEqual([]);
247+
});
248+
it.each([
249+
[{ id: "other", isPro: true }, "Forbidden"],
250+
[{ id: "owner", isPro: false }, "Cap Pro"],
251+
] as const)(
252+
"requires the recording owner with edit access",
253+
async (user, message) => {
254+
legacyUpload();
255+
mocks.auth.mockResolvedValue(user);
256+
await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(message);
257+
expect(mocks.quiescent).not.toHaveBeenCalled();
258+
expect(mocks.writes).toEqual([]);
259+
},
260+
);
261+
it("does not use legacy recovery to replace a current operation", async () => {
262+
legacyUpload();
263+
mocks.video.metadata = { editProcessing: { token: "current" } };
264+
await expect(restoreVideoToOriginal(videoId)).rejects.toThrow(
265+
"already uploading",
266+
);
267+
expect(mocks.quiescent).not.toHaveBeenCalled();
268+
expect(mocks.writes).toEqual([]);
269+
});
270+
});

apps/web/__tests__/unit/video-edit-processing.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ vi.mock("@/app/s/[videoId]/edit/EditUpgradeGate", () => ({}));
114114
vi.mock("@/app/s/[videoId]/edit/EditVideoClient", () => ({
115115
EditVideoClient: () => null,
116116
}));
117+
vi.mock("@/app/s/[videoId]/edit/edit-recovery", () => ({
118+
EditRecovery: () => null,
119+
}));
117120

118121
describe("viewing a recording during an edit", () => {
119122
beforeEach(() => {
@@ -152,11 +155,12 @@ describe("viewing a recording during an edit", () => {
152155
const edit = EditVideoPage({
153156
params: Promise.resolve({ videoId: "video123" }),
154157
});
155-
if (phase === "processing" || phase === "generating_thumbnail") {
156-
await expect(edit).rejects.toThrow("NEXT_NOT_FOUND");
157-
} else {
158-
expect(isValidElement(await edit)).toBe(true);
159-
}
158+
const element = await edit;
159+
const { EditRecovery } = await import(
160+
"@/app/s/[videoId]/edit/edit-recovery"
161+
);
162+
expect(isValidElement(element)).toBe(true);
163+
expect(element.type).toBe(EditRecovery);
160164

161165
expect(database.state.upload).toBe(upload);
162166
expect(database.remove).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)