Add CRUD support for training videos
这个提交包含在:
@@ -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",
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户