Skip to content

Commit c5f22d7

Browse files
Merge pull request #2233 from CapSoftware/feature/editable-ai-content
feat: edit AI summaries and chapters
2 parents 84e13f2 + bac8704 commit c5f22d7

14 files changed

Lines changed: 1278 additions & 26 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
formatChapterTime,
4+
MAX_CHAPTERS,
5+
parseChapterTime,
6+
validateAiContent,
7+
} from "@/lib/ai-content";
8+
9+
const content = (starts: number[]) => ({
10+
summary: "A summary",
11+
chapters: starts.map((start) => ({ title: "Topic", start })),
12+
});
13+
14+
describe("chapter timestamps", () => {
15+
it.each([
16+
["00:00", 0],
17+
["02:30", 150],
18+
["90:10", 5410],
19+
["1:30:10.125", 5410.125],
20+
[" 00:01.5 ", 1.5],
21+
])("parses %s", (text, seconds) => {
22+
expect(parseChapterTime(text)).toBe(seconds);
23+
});
24+
it.each([
25+
"",
26+
"12",
27+
"1:60",
28+
"1:99:00",
29+
"-1:00",
30+
"1:2",
31+
"1:00garbage",
32+
"NaN",
33+
"Infinity",
34+
])("rejects %s", (value) => {
35+
expect(parseChapterTime(value)).toBeNaN();
36+
});
37+
it.each([0, 1.123, 59.999, 60, 3599, 3600, 5410.125, 86400])(
38+
"round-trips %s seconds",
39+
(seconds) => {
40+
expect(parseChapterTime(formatChapterTime(seconds))).toBe(seconds);
41+
},
42+
);
43+
});
44+
45+
describe("AI content validation", () => {
46+
it("allows removing all summary and chapters", () => {
47+
expect(validateAiContent({ summary: "", chapters: [] }, 10)).toBeNull();
48+
});
49+
it("allows strictly ordered chapters within duration", () => {
50+
expect(validateAiContent(content([0, 10.125, 59]), 60)).toBeNull();
51+
});
52+
it.each([
53+
[0, 0],
54+
[10, 5],
55+
])("rejects duplicate or unordered times %j", (...starts) => {
56+
expect(validateAiContent(content(starts), 60)).toContain(
57+
"increasing order",
58+
);
59+
});
60+
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1])(
61+
"rejects invalid time %s",
62+
(start) => {
63+
expect(validateAiContent(content([start]))).toContain("valid timestamp");
64+
},
65+
);
66+
it("rejects timestamps at or beyond the end", () => {
67+
expect(validateAiContent(content([60]), 60)).toContain(
68+
"before the video ends",
69+
);
70+
});
71+
it("supports recordings with unknown duration", () => {
72+
expect(validateAiContent(content([0, 600]), null)).toBeNull();
73+
});
74+
it("rejects blank titles and oversized content", () => {
75+
expect(
76+
validateAiContent({ summary: "", chapters: [{ title: " ", start: 0 }] }),
77+
).toContain("needs a title");
78+
expect(
79+
validateAiContent({ summary: "x".repeat(50001), chapters: [] }),
80+
).toContain("summary under");
81+
expect(
82+
validateAiContent(
83+
content(Array.from({ length: MAX_CHAPTERS + 1 }, (_, i) => i)),
84+
),
85+
).toContain("no more than");
86+
});
87+
});
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { MySqlDialect } from "drizzle-orm/mysql-core";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
getCurrentUser: vi.fn(),
6+
transaction: vi.fn(),
7+
revalidatePath: vi.fn(),
8+
entitled: vi.fn(),
9+
lockedRead: vi.fn(),
10+
write: vi.fn(),
11+
}));
12+
vi.mock("@cap/database", () => ({
13+
db: () => ({ transaction: mocks.transaction }),
14+
}));
15+
vi.mock("@cap/database/auth/session", () => ({
16+
getCurrentUser: mocks.getCurrentUser,
17+
}));
18+
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
19+
vi.mock("@/lib/ai-generation-entitlement", () => ({
20+
isAiGenerationEnabledForUser: mocks.entitled,
21+
}));
22+
23+
import type { Video } from "@cap/web-domain";
24+
import { sql } from "drizzle-orm";
25+
import { editAiContent } from "@/actions/videos/edit-ai-content";
26+
import { setGeneratedAiContent } from "@/lib/ai-content-metadata";
27+
28+
const videoId = "video-id" as Video.VideoId;
29+
const expected = {
30+
summary: "Original",
31+
chapters: [{ title: "Intro", start: 0 }],
32+
};
33+
let readSql: string;
34+
let writeSql: string;
35+
let writeParams: unknown[];
36+
let metadata: Record<string, unknown>;
37+
38+
beforeEach(() => {
39+
metadata = {
40+
...expected,
41+
aiGenerationStatus: "COMPLETE",
42+
customCreatedAt: "2026-01-01",
43+
};
44+
mocks.getCurrentUser.mockResolvedValue({ id: "owner" });
45+
mocks.entitled.mockReturnValue(true);
46+
mocks.lockedRead.mockImplementation(async () => [
47+
{ metadata, duration: 120 },
48+
]);
49+
mocks.write.mockResolvedValue([{ affectedRows: 1 }]);
50+
const dialect = new MySqlDialect();
51+
const tx = {
52+
select: () => ({
53+
from: () => ({
54+
where: (condition: Parameters<MySqlDialect["sqlToQuery"]>[0]) => {
55+
readSql = JSON.stringify(dialect.sqlToQuery(condition));
56+
return { for: mocks.lockedRead };
57+
},
58+
}),
59+
}),
60+
update: () => ({
61+
set: (values: {
62+
metadata: Parameters<MySqlDialect["sqlToQuery"]>[0];
63+
}) => {
64+
const query = dialect.sqlToQuery(values.metadata);
65+
writeSql = query.sql;
66+
writeParams = query.params;
67+
return { where: mocks.write };
68+
},
69+
}),
70+
};
71+
mocks.transaction.mockImplementation((callback) => callback(tx));
72+
});
73+
74+
describe("editing AI content", () => {
75+
it("does not treat existing surrounding whitespace as a manual edit", async () => {
76+
const original = {
77+
summary: " Original ",
78+
chapters: [{ title: " Intro ", start: 0 }],
79+
};
80+
metadata = { ...original, aiGenerationStatus: "COMPLETE" };
81+
expect(
82+
await editAiContent(videoId, { expected: original, value: original }),
83+
).toEqual({ success: true, data: original });
84+
expect(mocks.write).not.toHaveBeenCalled();
85+
await editAiContent(videoId, {
86+
expected: original,
87+
value: { ...original, summary: "Changed" },
88+
});
89+
expect(writeSql).not.toContain("chaptersManuallyEdited");
90+
});
91+
it("compares chapters independently of MySQL JSON key ordering", async () => {
92+
metadata.chapters = [{ start: 0, title: "Intro" }];
93+
expect(
94+
(
95+
await editAiContent(videoId, {
96+
expected,
97+
value: { ...expected, chapters: [{ title: "Renamed", start: 0 }] },
98+
})
99+
).success,
100+
).toBe(true);
101+
});
102+
it("blocks edits during transcription after a media change", async () => {
103+
mocks.lockedRead.mockResolvedValueOnce([
104+
{ metadata, duration: 120, transcriptionStatus: "PROCESSING" },
105+
]);
106+
expect(
107+
(
108+
await editAiContent(videoId, {
109+
expected,
110+
value: { ...expected, summary: "Edited" },
111+
})
112+
).success,
113+
).toBe(false);
114+
expect(mocks.write).not.toHaveBeenCalled();
115+
});
116+
it("requires authentication and Pro entitlement", async () => {
117+
mocks.getCurrentUser.mockResolvedValueOnce(null);
118+
expect((await editAiContent(videoId, {})).success).toBe(false);
119+
mocks.entitled.mockReturnValueOnce(false);
120+
expect((await editAiContent(videoId, {})).success).toBe(false);
121+
expect(mocks.transaction).not.toHaveBeenCalled();
122+
});
123+
it("scopes the locked row to its owner", async () => {
124+
mocks.lockedRead.mockResolvedValueOnce([]);
125+
expect(
126+
(
127+
await editAiContent(videoId, {
128+
expected,
129+
value: { ...expected, summary: "Edited" },
130+
})
131+
).success,
132+
).toBe(false);
133+
expect(readSql).toContain("ownerId");
134+
expect(readSql).toContain("owner");
135+
expect(mocks.lockedRead).toHaveBeenCalledWith("update");
136+
expect(mocks.write).not.toHaveBeenCalled();
137+
});
138+
it("updates only the summary while retaining concurrently updated chapters", async () => {
139+
metadata.chapters = [{ title: "Updated elsewhere", start: 10 }];
140+
const result = await editAiContent(videoId, {
141+
expected,
142+
value: { ...expected, summary: " **Edited** " },
143+
});
144+
expect(result).toEqual({
145+
success: true,
146+
data: { summary: "**Edited**", chapters: metadata.chapters },
147+
});
148+
expect(writeSql).toContain("JSON_SET");
149+
expect(writeSql).toContain("summaryManuallyEdited");
150+
expect(writeSql).not.toContain("chaptersManuallyEdited");
151+
expect(writeParams).toContain("**Edited**");
152+
expect(mocks.revalidatePath).toHaveBeenCalledWith("/s/video-id");
153+
});
154+
it("updates chapters as JSON and preserves a concurrent summary edit", async () => {
155+
metadata.summary = "Updated elsewhere";
156+
const result = await editAiContent(videoId, {
157+
expected,
158+
value: { ...expected, chapters: [{ title: " Changed ", start: 20 }] },
159+
});
160+
expect(result).toEqual({
161+
success: true,
162+
data: {
163+
summary: "Updated elsewhere",
164+
chapters: [{ title: "Changed", start: 20 }],
165+
},
166+
});
167+
expect(writeSql).toContain("CAST(? AS JSON)");
168+
expect(writeSql).not.toContain("summaryManuallyEdited");
169+
expect(writeParams).toContain('[{"title":"Changed","start":20}]');
170+
});
171+
it("rejects stale edits without writing", async () => {
172+
metadata.summary = "Newer saved summary";
173+
const result = await editAiContent(videoId, {
174+
expected,
175+
value: { ...expected, summary: "Stale edit" },
176+
});
177+
expect(result).toMatchObject({
178+
success: false,
179+
message: expect.stringContaining("changed since"),
180+
});
181+
expect(mocks.write).not.toHaveBeenCalled();
182+
});
183+
it.each(["QUEUED", "PROCESSING"])(
184+
"rejects edits while %s",
185+
async (status) => {
186+
metadata.aiGenerationStatus = status;
187+
expect(
188+
(
189+
await editAiContent(videoId, {
190+
expected,
191+
value: { ...expected, summary: "Edited" },
192+
})
193+
).success,
194+
).toBe(false);
195+
expect(mocks.write).not.toHaveBeenCalled();
196+
},
197+
);
198+
it.each([
199+
{ summary: 123, chapters: [] },
200+
{ summary: "x", chapters: [{ title: "Bad", start: Number.NaN }] },
201+
{ summary: "x", chapters: [{ title: "Bad", start: 120 }] },
202+
{ summary: "x", chapters: [{ title: "", start: 0 }] },
203+
{
204+
summary: "x",
205+
chapters: [
206+
{ title: "A", start: 10 },
207+
{ title: "B", start: 10 },
208+
],
209+
},
210+
])("rejects malformed or invalid content", async (value) => {
211+
expect((await editAiContent(videoId, { expected, value })).success).toBe(
212+
false,
213+
);
214+
expect(mocks.write).not.toHaveBeenCalled();
215+
});
216+
it("allows deliberate removal and avoids writing unchanged data", async () => {
217+
expect(
218+
(await editAiContent(videoId, { expected, value: expected })).success,
219+
).toBe(true);
220+
expect(mocks.write).not.toHaveBeenCalled();
221+
expect(
222+
await editAiContent(videoId, {
223+
expected,
224+
value: { summary: "", chapters: [] },
225+
}),
226+
).toEqual({ success: true, data: { summary: "", chapters: [] } });
227+
expect(writeSql).toContain("summaryManuallyEdited");
228+
expect(writeSql).toContain("chaptersManuallyEdited");
229+
});
230+
it("reports storage failures without pretending the draft was saved", async () => {
231+
vi.spyOn(console, "error").mockImplementation(() => {});
232+
mocks.write.mockRejectedValueOnce(new Error("database unavailable"));
233+
expect(
234+
(
235+
await editAiContent(videoId, {
236+
expected,
237+
value: { ...expected, summary: "Edited" },
238+
})
239+
).success,
240+
).toBe(false);
241+
expect(mocks.revalidatePath).not.toHaveBeenCalled();
242+
});
243+
});
244+
245+
describe("generation preserves manual content", () => {
246+
it.each(["summary", "chapters"] as const)(
247+
"guards %s using the current row and permits regeneration after content removal",
248+
(field) => {
249+
const query = new MySqlDialect().sqlToQuery(
250+
setGeneratedAiContent(
251+
sql`JSON_OBJECT()`,
252+
field,
253+
field === "summary" ? "Generated" : [],
254+
),
255+
);
256+
expect(query.sql).toContain("JSON_CONTAINS_PATH");
257+
expect(query.sql).toContain("IF(");
258+
expect(query.params).toContain(`$.${field}ManuallyEdited`);
259+
expect(query.params).toContain(`$.${field}`);
260+
expect(query.sql).toContain("CAST('false' AS JSON)");
261+
},
262+
);
263+
});

apps/web/__tests__/unit/generate-ai-start.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ beforeEach(() => {
8080
});
8181

8282
describe("startAiGeneration", () => {
83+
it("queues without replacing concurrently edited metadata", async () => {
84+
const update = makeUpdateChain(1);
85+
mockDb
86+
.mockReturnValueOnce(makeSelectChain(video))
87+
.mockReturnValueOnce(update);
88+
const { startAiGeneration } = await import("@/lib/generate-ai");
89+
await startAiGeneration("video-1" as never, "user-1");
90+
const value = update.set.mock.calls[0]?.[0];
91+
expect(value.metadata.strings.join("")).toContain("JSON_SET(COALESCE(");
92+
expect(value.metadata.strings.join("")).toContain(
93+
"'$.aiGenerationStatus', 'QUEUED'",
94+
);
95+
expect(value.metadata.values).toEqual(["videos.metadata"]);
96+
});
8397
it("fails fast when no AI provider is configured", async () => {
8498
serverEnvMock.mockReturnValue({});
8599

0 commit comments

Comments
 (0)