Add CRUD support for training videos

这个提交包含在:
cryptocommuniums-afk
2026-03-15 14:17:59 +08:00
父节点 143c60a054
当前提交 bd8998166b
修改 5 个文件,包含 877 行新增71 行删除

查看文件

@@ -20,6 +20,19 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
skillLevel: "beginner",
trainingGoals: null,
ntrpRating: 1.5,
manualNtrpRating: null,
manualNtrpCapturedAt: null,
heightCm: null,
weightKg: null,
sprintSpeedScore: null,
explosivePowerScore: null,
agilityScore: null,
enduranceScore: null,
flexibilityScore: null,
coreStabilityScore: null,
shoulderMobilityScore: null,
hipMobilityScore: null,
assessmentNotes: null,
totalSessions: 0,
totalMinutes: 0,
totalShots: 0,
@@ -101,6 +114,28 @@ describe("auth.logout", () => {
path: "/",
});
});
it("uses lax non-secure cookies for plain http requests", async () => {
const user = createTestUser();
const { ctx, clearedCookies } = createMockContext(user);
ctx.req = {
protocol: "http",
headers: {},
} as TrpcContext["req"];
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: false,
sameSite: "lax",
httpOnly: true,
path: "/",
});
});
});
describe("auth.loginWithUsername input validation", () => {
@@ -217,6 +252,30 @@ describe("profile.update input validation", () => {
}
}
});
it("accepts training assessment fields", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
try {
await caller.profile.update({
heightCm: 178,
weightKg: 68,
sprintSpeedScore: 4,
explosivePowerScore: 3,
agilityScore: 4,
enduranceScore: 3,
flexibilityScore: 3,
coreStabilityScore: 4,
shoulderMobilityScore: 3,
hipMobilityScore: 4,
manualNtrpRating: 2.5,
});
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
});
// ===== TRAINING PLAN TESTS =====
@@ -259,6 +318,19 @@ describe("plan.generate input validation", () => {
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
).rejects.toThrow();
});
it("rejects generation when training profile is incomplete", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(db, "getUserById").mockResolvedValueOnce(user);
vi.spyOn(db, "getLatestNtrpSnapshot").mockResolvedValueOnce(null as any);
await expect(
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
).rejects.toThrow(/训练计划生成前请先完善训练档案/);
});
});
describe("plan.list", () => {
@@ -376,6 +448,152 @@ describe("video.get input validation", () => {
});
});
describe("video.get", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns the current user's video", async () => {
const user = createTestUser({ id: 42 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const createdAt = new Date("2026-03-15T06:00:00.000Z");
vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce({
id: 9,
userId: 42,
title: "Forehand Session",
fileKey: "videos/42/forehand.mp4",
url: "https://cdn.example.com/videos/42/forehand.mp4",
format: "mp4",
fileSize: 1024,
duration: 12,
exerciseType: "forehand",
analysisStatus: "completed",
createdAt,
updatedAt: createdAt,
} as any);
const result = await caller.video.get({ videoId: 9 });
expect(result.title).toBe("Forehand Session");
expect(db.getUserVideoById).toHaveBeenCalledWith(42, 9);
});
it("throws not found for videos outside the current user scope", async () => {
const user = createTestUser({ id: 42 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce(undefined);
await expect(caller.video.get({ videoId: 999 })).rejects.toThrow("视频不存在");
});
});
describe("video.update input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.update({ videoId: 1, title: "updated title" })
).rejects.toThrow();
});
it("rejects empty title", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.update({ videoId: 1, title: "" })
).rejects.toThrow();
});
});
describe("video.update", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("updates the current user's video metadata", async () => {
const user = createTestUser({ id: 7 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const updateSpy = vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(true);
const result = await caller.video.update({
videoId: 14,
title: "Updated Backhand Session",
exerciseType: "backhand",
});
expect(result).toEqual({ success: true });
expect(updateSpy).toHaveBeenCalledWith(7, 14, {
title: "Updated Backhand Session",
exerciseType: "backhand",
});
});
it("throws not found when the video cannot be updated by the current user", async () => {
const user = createTestUser({ id: 7 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(false);
await expect(
caller.video.update({
videoId: 14,
title: "Updated Backhand Session",
exerciseType: "backhand",
})
).rejects.toThrow("视频不存在");
});
});
describe("video.delete input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.delete({ videoId: 1 })
).rejects.toThrow();
});
});
describe("video.delete", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("deletes the current user's video", async () => {
const user = createTestUser({ id: 11 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const deleteSpy = vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(true);
const result = await caller.video.delete({ videoId: 20 });
expect(result).toEqual({ success: true });
expect(deleteSpy).toHaveBeenCalledWith(11, 20);
});
it("throws not found when the current user does not own the video", async () => {
const user = createTestUser({ id: 11 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(false);
await expect(caller.video.delete({ videoId: 20 })).rejects.toThrow("视频不存在");
});
});
// ===== ANALYSIS TESTS =====
describe("analysis.save input validation", () => {
@@ -700,6 +918,17 @@ describe("tutorial.list", () => {
expect(e.message).not.toContain("invalid_type");
}
});
it("accepts topicArea filter", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
try {
await caller.tutorial.list({ topicArea: "tennis_skill" });
} catch (e: any) {
expect(e.message).not.toContain("invalid_type");
}
});
});
describe("tutorial.progress", () => {
@@ -729,7 +958,7 @@ describe("tutorial.updateProgress input validation", () => {
).rejects.toThrow();
});
it("accepts optional watched, selfScore, notes", async () => {
it("accepts optional watched, completed, selfScore, notes", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
@@ -738,6 +967,7 @@ describe("tutorial.updateProgress input validation", () => {
await caller.tutorial.updateProgress({
tutorialId: 1,
watched: 1,
completed: 1,
selfScore: 4,
notes: "Great tutorial",
});