diff --git a/packages/game-data-sdk/package.json b/packages/game-data-sdk/package.json new file mode 100644 index 00000000..28654d15 --- /dev/null +++ b/packages/game-data-sdk/package.json @@ -0,0 +1,24 @@ +{ + "name": "@earthworm/game-data-sdk", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "test": "env-cmd -f .env.test vitest", + "build": "tsup" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "env-cmd": "^10.1.0", + "tsup": "^8.0.1", + "vitest": "^2.0.3" + }, + "dependencies": { + "@earthworm/schema": "^1.0.1", + "drizzle-orm": "^0.32.0", + "postgres": "^3.4.4" + } +} diff --git a/packages/game-data-sdk/src/course-pack/course-pack.model.ts b/packages/game-data-sdk/src/course-pack/course-pack.model.ts new file mode 100644 index 00000000..ce7dd989 --- /dev/null +++ b/packages/game-data-sdk/src/course-pack/course-pack.model.ts @@ -0,0 +1,31 @@ +export interface Statement { + english: string; + phonetic: string; + chinese: string; +} + +interface Course { + title: string; + description: string; + statements: Statement[]; +} + +export interface CreateCoursePack { + title: string; + description: string; + cover: string; + uId: string; + shareLevel: string; + courses: Course[]; +} + +type UpdateCourse = Course & { publishCourseId: string }; + +export interface UpdateCoursePack { + title: string; + description: string; + cover: string; + uId: string; + shareLevel: string; + courses: UpdateCourse[]; +} diff --git a/packages/game-data-sdk/src/course-pack/course-pack.service.ts b/packages/game-data-sdk/src/course-pack/course-pack.service.ts new file mode 100644 index 00000000..f384a5a8 --- /dev/null +++ b/packages/game-data-sdk/src/course-pack/course-pack.service.ts @@ -0,0 +1,293 @@ +import { and, asc, eq } from "drizzle-orm"; + +import { + courseHistory as courseHistorySchema, + coursePack as coursePackSchema, + course as courseSchema, + statement as statementSchema, + userCourseProgress as userCourseProgressSchema, +} from "@earthworm/schema"; +import type { CreateCoursePack, Statement, UpdateCoursePack } from "./course-pack.model"; +import { db } from "../db"; + +export async function createCoursePack(coursePackInfo: CreateCoursePack) { + const result = await db.transaction(async (tx) => { + const coursePackOrder = await calculateCoursePackOrder(coursePackInfo.uId); + + const [coursePackEntity] = await tx + .insert(coursePackSchema) + .values({ + order: coursePackOrder, + creatorId: coursePackInfo.uId, + shareLevel: coursePackInfo.shareLevel, + title: coursePackInfo.title, + description: coursePackInfo.description, + cover: coursePackInfo.cover, + isFree: true, + }) + .returning(); + + const courseIds: string[] = []; + for (const [cIndex, course] of coursePackInfo.courses.entries()) { + const [courseEntity] = await tx + .insert(courseSchema) + .values({ + coursePackId: coursePackEntity.id, + order: cIndex + 1, + title: course.title, + description: course.description, + }) + .returning({ + id: courseSchema.id, + order: courseSchema.order, + title: courseSchema.title, + }); + + courseIds.push(courseEntity.id.toString()); + + const createStatementTasks = course.statements.map( + ({ chinese, english, phonetic }, sIndex) => { + return tx.insert(statementSchema).values({ + chinese, + english, + soundmark: phonetic, + order: sIndex + 1, + courseId: courseEntity.id, + }); + }, + ); + + await Promise.all(createStatementTasks); + } + + return { + coursePackId: coursePackEntity.id, + courseIds, + }; + + async function calculateCoursePackOrder(userId: string) { + const entity = await tx.query.coursePack.findFirst({ + orderBy: (table, { desc }) => [desc(table.order)], + where: (table, { eq }) => eq(table.creatorId, userId), + }); + + if (entity) { + return entity.order + 1; + } + + return 1; + } + }); + + return result; +} + +export async function deleteCoursePack(coursePackId: string) { + const result = await db.transaction(async (tx) => { + const coursePack = await tx.query.coursePack.findFirst({ + where: eq(coursePackSchema.id, coursePackId), + }); + + if (!coursePack) { + throw new Error("not found course pack"); + } + + const courses = await tx.query.course.findMany({ + where: eq(courseSchema.coursePackId, coursePackId), + }); + + const deleteStatementTasks = courses.map((course) => { + return tx.delete(statementSchema).where(eq(statementSchema.courseId, course.id)); + }); + + await Promise.all(deleteStatementTasks); + await tx.delete(courseSchema).where(eq(courseSchema.coursePackId, coursePackId)); + await tx.delete(coursePackSchema).where(eq(coursePackSchema.id, coursePackId)); + + // 还需要删除 course_history + // 和 user_course_progress 里面的记录 + await tx.delete(courseHistorySchema).where(eq(courseHistorySchema.coursePackId, coursePackId)); + + await tx + .delete(userCourseProgressSchema) + .where(eq(userCourseProgressSchema.coursePackId, coursePackId)); + + return true; + }); + + return result; +} + +export async function updateCoursePack(coursePackId: string, coursePackInfo: UpdateCoursePack) { + const result = await db.transaction(async (tx) => { + async function _updateCoursePack() { + await tx + .update(coursePackSchema) + .set({ + title: coursePackInfo.title, + description: coursePackInfo.description, + cover: coursePackInfo.cover, + shareLevel: coursePackInfo.shareLevel, + }) + .where(eq(coursePackSchema.id, coursePackId)); + } + + async function _updateCourses() { + const courseIds: string[] = []; + const oldCourses = await tx.query.course.findMany({ + where: eq(courseSchema.coursePackId, coursePackId), + orderBy: [asc(courseSchema.order)], + }); + + const oldCourseMap = new Map(oldCourses.map((course) => [course.id, course])); + const newCourseMap = new Map( + coursePackInfo.courses.map((course) => [course.publishCourseId, course]), + ); + + // 新的在老的里面存在 那么更新 + for (const [newCourseIndex, newCourseInfo] of coursePackInfo.courses.entries()) { + if (oldCourseMap.has(newCourseInfo.publishCourseId)) { + // Update existing course + await tx + .update(courseSchema) + .set({ + title: newCourseInfo.title, + description: newCourseInfo.description, + }) + .where(eq(courseSchema.id, newCourseInfo.publishCourseId)); + + courseIds.push(newCourseInfo.publishCourseId); + + await _updateStatements(newCourseInfo.publishCourseId, newCourseInfo.statements); + } else { + // Create new course + // 新的在老的里面不存在 那么创建 + const [courseEntity] = await tx + .insert(courseSchema) + .values({ + title: newCourseInfo.title, + description: newCourseInfo.description, + order: newCourseIndex + 1, + coursePackId: coursePackId, + }) + .returning({ + id: courseSchema.id, + order: courseSchema.order, + title: courseSchema.title, + }); + + courseIds.push(courseEntity.id.toString()); + + const createStatementTasks = newCourseInfo.statements.map( + async ({ chinese, english, phonetic }, sIndex) => { + return tx.insert(statementSchema).values({ + chinese, + english, + soundmark: phonetic, + order: sIndex + 1, + courseId: courseEntity.id, + }); + }, + ); + + await Promise.all(createStatementTasks); + } + } + + // 老的在新的里面不存在 那么删除 + for (const oldCourse of oldCourses) { + if (!newCourseMap.has(oldCourse.id)) { + // Delete course if it is not in the new course pack info + await tx.delete(statementSchema).where(eq(statementSchema.courseId, oldCourse.id)); + await tx.delete(courseSchema).where(eq(courseSchema.id, oldCourse.id)); + + // Delete related records in course_history and user_course_progress + await tx + .delete(courseHistorySchema) + .where( + and( + eq(courseHistorySchema.coursePackId, coursePackId), + eq(courseHistorySchema.courseId, oldCourse.id), + ), + ); + + await tx + .delete(userCourseProgressSchema) + .where( + and( + eq(userCourseProgressSchema.coursePackId, coursePackId), + eq(userCourseProgressSchema.courseId, oldCourse.id), + ), + ); + } + } + + return courseIds; + } + + async function _updateStatements(courseId: string, newStatements: Statement[]) { + const oldStatements = await tx.query.statement.findMany({ + where: eq(statementSchema.courseId, courseId), + orderBy: [asc(statementSchema.order)], + }); + + let oldIndex = 0; + let newIndex = 0; + + while (oldIndex < oldStatements.length && newIndex < newStatements.length) { + const newStatementInfo = newStatements[newIndex]; + const oldStatement = oldStatements[oldIndex]; + + await tx + .update(statementSchema) + .set({ + english: newStatementInfo.english, + chinese: newStatementInfo.chinese, + soundmark: newStatementInfo.phonetic, + }) + .where(eq(statementSchema.id, oldStatement.id)); + + oldIndex++; + newIndex++; + } + + // 如果新的课程statements更多,创建剩余的新课程 + while (newIndex < newStatements.length) { + const newStatementInfo = newStatements[newIndex]; + await tx.insert(statementSchema).values({ + english: newStatementInfo.english, + chinese: newStatementInfo.chinese, + soundmark: newStatementInfo.phonetic, + order: newIndex + 1, + courseId, + }); + newIndex++; + } + + // 如果旧的课程信息更多,删除剩余的旧课程 + while (oldIndex < oldStatements.length) { + const oldStatement = oldStatements[oldIndex]; + await tx.delete(statementSchema).where(and(eq(statementSchema.id, oldStatement.id))); + oldIndex++; + } + } + + const coursePack = await tx.query.coursePack.findFirst({ + where: and( + eq(coursePackSchema.id, coursePackId), + eq(coursePackSchema.creatorId, coursePackInfo.uId), + ), + }); + + if (!coursePack) { + throw new Error("not found course pack"); + } + + await _updateCoursePack(); + const courseIds = await _updateCourses(); + + return { courseIds }; + }); + + return result; +} diff --git a/packages/game-data-sdk/src/course-pack/tests/course-pack.service.spec.ts b/packages/game-data-sdk/src/course-pack/tests/course-pack.service.spec.ts new file mode 100644 index 00000000..f29d13c6 --- /dev/null +++ b/packages/game-data-sdk/src/course-pack/tests/course-pack.service.spec.ts @@ -0,0 +1,780 @@ +import { and, asc, eq, or } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { + courseHistory as courseHistorySchema, + coursePack as coursePackSchema, + course as courseSchema, + statement as statementSchema, + userCourseProgress as userCourseProgressSchema, +} from "@earthworm/schema"; +import { cleanDB, db } from "../../db"; +import { createCoursePack, deleteCoursePack, updateCoursePack } from "../course-pack.service"; + +describe("course pack service", () => { + beforeEach(async () => { + // 清空数据库 + await cleanDB(db); + }); + + afterAll(async () => { + await cleanDB(db); + }); + + describe("create course pack", () => { + it("should create a new course pack with all fields correctly provided", async () => { + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + expect(result.courseIds.length).toBe(mockData.courses.length); + }); + + it("should return the correct result with course pack ID, title, and course ID list", async () => { + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + expect(result.courseIds.length).toBe(mockData.courses.length); + }); + + it("should correctly calculate the course pack order", async () => { + const mockData = createCoursePackMockData(); + + // Create the first course pack + const firstResult = await createCoursePack(mockData); + expect(firstResult.coursePackId).toBeDefined(); + + // Query the order of the first course pack + const firstCoursePackOrder = await db.query.coursePack.findFirst({ + where: (coursePacks, { eq }) => eq(coursePacks.id, firstResult.coursePackId), + columns: { order: true }, + }); + expect(firstCoursePackOrder!.order).toBe(1); + + // Create the second course pack + const secondResult = await createCoursePack(mockData); + expect(secondResult.coursePackId).toBeDefined(); + + // Query the order of the second course pack + const secondCoursePackOrder = await db.query.coursePack.findFirst({ + where: (coursePacks, { eq }) => eq(coursePacks.id, secondResult.coursePackId), + columns: { order: true }, + }); + expect(secondCoursePackOrder!.order).toBe(2); + }); + }); + + describe("delete course pack", () => { + it("should delete a course pack and related entities", async () => { + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + const deleteResult = await deleteCoursePack(result.coursePackId); + expect(deleteResult).toBe(true); + + // Check if the course pack is deleted + const coursePack = await db.query.coursePack.findFirst({ + where: eq(coursePackSchema.id, result.coursePackId), + }); + expect(coursePack).toBeUndefined(); + + // Check if the related courses are deleted + const courses = await db.query.course.findMany({ + where: eq(courseSchema.coursePackId, result.coursePackId), + }); + expect(courses.length).toBe(0); + + // Check if the related statements are deleted + const statements = await db.query.statement.findMany({ + where: or(...result.courseIds.map((courseId) => eq(statementSchema.courseId, courseId))), + }); + expect(statements.length).toBe(0); + }); + + it("should delete course history related to the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + + // Insert course history data + await db.insert(courseHistorySchema).values({ + coursePackId: result.coursePackId, + courseId: "", + completionCount: 1, + userId: "user123", + }); + + // Delete the course pack + const deleteResult = await deleteCoursePack(result.coursePackId); + expect(deleteResult).toBe(true); + + // Check if the course history is deleted + const courseHistory = await db.query.courseHistory.findMany({ + where: eq(courseHistorySchema.coursePackId, result.coursePackId), + }); + expect(courseHistory.length).toBe(0); + }); + + it("should delete user course progress related to the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + + // Insert user course progress data + await db.insert(userCourseProgressSchema).values({ + coursePackId: result.coursePackId, + userId: "user123", + courseId: "", + statementIndex: 0, + }); + + // Delete the course pack + const deleteResult = await deleteCoursePack(result.coursePackId); + expect(deleteResult).toBe(true); + + // Check if the user course progress is deleted + const userCourseProgress = await db.query.userCourseProgress.findMany({ + where: eq(userCourseProgressSchema.coursePackId, result.coursePackId), + }); + expect(userCourseProgress.length).toBe(0); + }); + + it("should not delete course history unrelated to the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + + // Insert unrelated course history data + await db.insert(courseHistorySchema).values({ + coursePackId: "unrelated-course-pack-id", + userId: "user123", + courseId: "", + completionCount: 50, + }); + + // Delete the course pack + const deleteResult = await deleteCoursePack(result.coursePackId); + expect(deleteResult).toBe(true); + + // Check if the unrelated course history is still present + const courseHistory = await db.query.courseHistory.findMany({ + where: eq(courseHistorySchema.coursePackId, "unrelated-course-pack-id"), + }); + expect(courseHistory.length).toBe(1); + }); + + it("should not delete user course progress unrelated to the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + + // Insert unrelated user course progress data + await db.insert(userCourseProgressSchema).values({ + coursePackId: "unrelated-course-pack-id", + userId: "user123", + courseId: "", + statementIndex: 0, + }); + + // Delete the course pack + const deleteResult = await deleteCoursePack(result.coursePackId); + expect(deleteResult).toBe(true); + + // Check if the unrelated user course progress is still present + const userCourseProgress = await db.query.userCourseProgress.findMany({ + where: eq(userCourseProgressSchema.coursePackId, "unrelated-course-pack-id"), + }); + expect(userCourseProgress.length).toBe(1); + }); + + it("should return false if the course pack does not exist", async () => { + await expect(async () => { + await deleteCoursePack("non-existent-id"); + }).rejects.toThrow("not found course pack"); + }); + }); + + describe("update course pack", () => { + it("should update basic information of the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack information + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toEqual({ + courseIds: [], + }); + + // Check if the course pack information is updated + const updatedCoursePack = await db.query.coursePack.findFirst({ + where: eq(coursePackSchema.id, result.coursePackId), + }); + + expect(updatedCoursePack).toBeDefined(); + expect(updatedCoursePack!.title).toBe(updateInfo.title); + expect(updatedCoursePack!.description).toBe(updateInfo.description); + expect(updatedCoursePack!.cover).toBe(updateInfo.cover); + }); + + it("should only allow the creator to update the course pack information", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Attempt to update with a different user ID + const updateInfo = { + uId: "different-user-id", + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + courses: [], + shareLevel: "public", + }; + + await expect(async () => { + await updateCoursePack(result.coursePackId, updateInfo); + }).rejects.toThrow("not found course pack"); + }); + + it("should return false if the course pack does not exist", async () => { + // Attempt to update a non-existent course pack + const updateInfo = { + uId: "user123", + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + courses: [], + shareLevel: "public", + }; + + await expect(async () => { + await updateCoursePack("non-existent-course-pack-id", updateInfo); + }).rejects.toThrow("not found course pack"); + }); + + describe("update course", () => { + it("should update existing course information", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course information + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], + statements: mockData.courses[0].statements, + }, + ], + }; + + await updateCoursePack(result.coursePackId, updateInfo); + // Check if the course information is updated + const updatedCourse = await db.query.course.findFirst({ + where: eq(courseSchema.id, updateInfo.courses[0].publishCourseId), + }); + + expect(updatedCourse).toBeDefined(); + expect(updatedCourse!.title).toBe(updateInfo.courses[0].title); + expect(updatedCourse!.description).toBe(updateInfo.courses[0].description); + }); + + it("should create new courses and add them to the course pack", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + mockData.courses[0].publishCourseId = result.courseIds[0]; + mockData.courses[1].publishCourseId = result.courseIds[1]; + // Update course pack with new courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + ...mockData.courses, + { + title: "New Course Title", + description: "New Course Description", + publishCourseId: "", + statements: [ + { + english: "New English Statement", + chinese: "New Chinese Statement", + phonetic: "New Phonetic", + }, + ], + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the new course is created + const newCourseId = updateResult.courseIds[2] || ""; + const newCourse = await db.query.course.findFirst({ + where: eq(courseSchema.id, newCourseId), + }); + + expect(newCourse).toBeDefined(); + expect(newCourse!.title).toBe(updateInfo.courses[2].title); + expect(newCourse!.description).toBe(updateInfo.courses[2].description); + }); + + it("should delete existing courses that are no longer needed", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack with fewer courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], + statements: mockData.courses[0].statements, + }, + ], + }; + await updateCoursePack(result.coursePackId, updateInfo); + // 删除了一个 只剩下一个 + const courses = await db.query.course.findMany(); + expect(courses.length).toBe(1); + }); + + it("should delete course history records when a course is deleted", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Insert course history records + await db.insert(courseHistorySchema).values({ + coursePackId: result.coursePackId, + courseId: result.courseIds[1], // Use the publishCourseId from the result + userId: "user123", + completionCount: 50, + }); + + // Update course pack with fewer courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: mockData.courses[0].statements, + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the course history records are deleted + const courseHistory = await db.query.courseHistory.findFirst({ + where: and( + eq(courseHistorySchema.coursePackId, result.coursePackId), + eq(courseHistorySchema.courseId, result.courseIds[1]), // Use the publishCourseId from the result + ), + }); + + expect(courseHistory).toBeUndefined(); + }); + + it("should delete user course progress records when a course is deleted", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Insert user course progress records + await db.insert(userCourseProgressSchema).values({ + coursePackId: result.coursePackId, + courseId: result.courseIds[1], // Use the publishCourseId from the result + userId: "user123", + statementIndex: 1, + }); + + // Update course pack with fewer courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: mockData.courses[0].statements, + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the user course progress records are deleted + const userCourseProgress = await db.query.userCourseProgress.findFirst({ + where: and( + eq(userCourseProgressSchema.coursePackId, result.coursePackId), + eq(userCourseProgressSchema.courseId, result.courseIds[1]), // Use the publishCourseId from the result + ), + }); + + expect(userCourseProgress).toBeUndefined(); + }); + + it("should not affect publishCourseId in courseHistorySchema after deleting a course", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Insert course history records + await db.insert(courseHistorySchema).values({ + coursePackId: result.coursePackId, + courseId: result.courseIds[0], // Use the publishCourseId from the result + userId: "user123", + completionCount: 50, + }); + + // Update course pack with fewer courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: mockData.courses[0].statements, + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the course history records are intact + const courseHistory = await db.query.courseHistory.findFirst({ + where: and( + eq(courseHistorySchema.coursePackId, result.coursePackId), + eq(courseHistorySchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + ), + }); + + expect(courseHistory).toBeDefined(); + }); + + it("should not affect publishCourseId in userCourseProgressSchema after deleting a course", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + expect(result).toBeDefined(); + expect(result.coursePackId).toBeDefined(); + + // Insert user course progress records + await db.insert(userCourseProgressSchema).values({ + coursePackId: result.coursePackId, + courseId: result.courseIds[0], // Use the publishCourseId from the result + userId: "user123", + statementIndex: 1, + }); + + // Update course pack with fewer courses + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: mockData.courses[0].statements, + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the user course progress records are intact + const userCourseProgress = await db.query.userCourseProgress.findFirst({ + where: and( + eq(userCourseProgressSchema.coursePackId, result.coursePackId), + eq(userCourseProgressSchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + ), + }); + + expect(userCourseProgress).toBeDefined(); + }); + }); + + describe("update statements", () => { + it("should update existing statement information", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack with updated statements + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: [ + { + english: "Updated English Statement", + chinese: "Updated Chinese Statement", + phonetic: "Updated Phonetic", + }, + ], + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the statement information is updated + const updatedStatement = await db.query.statement.findFirst({ + where: eq(statementSchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + }); + + expect(updatedStatement).toBeDefined(); + expect(updatedStatement!.english).toBe(updateInfo.courses[0].statements[0].english); + expect(updatedStatement!.chinese).toBe(updateInfo.courses[0].statements[0].chinese); + expect(updatedStatement!.soundmark).toBe(updateInfo.courses[0].statements[0].phonetic); + }); + + it("should create new statements and add them to the course", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack with new statements + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: [ + ...mockData.courses[0].statements, + { + english: "New English Statement", + chinese: "New Chinese Statement", + phonetic: "New Phonetic", + }, + ], + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the new statement is created + const newStatement = await db.query.statement.findFirst({ + where: and( + eq(statementSchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + eq(statementSchema.english, "New English Statement"), + ), + }); + + expect(newStatement).toBeDefined(); + expect(newStatement!.chinese).toBe("New Chinese Statement"); + expect(newStatement!.soundmark).toBe("New Phonetic"); + }); + + it("should delete existing statements that are no longer needed", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack with fewer statements + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: [ + { + english: "Updated English Statement", + chinese: "Updated Chinese Statement", + phonetic: "Updated Phonetic", + }, + ], + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the statement is deleted + const deletedStatement = await db.query.statement.findFirst({ + where: and( + eq(statementSchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + eq(statementSchema.english, mockData.courses[0].statements[1].english), + ), + }); + + expect(deletedStatement).toBeUndefined(); + }); + + it("should update statement order", async () => { + // Create a course pack + const mockData = createCoursePackMockData(); + const result = await createCoursePack(mockData); + + // Update course pack with reordered statements + const updateInfo = { + uId: mockData.uId, + title: "Updated Title", + description: "Updated Description", + cover: "https://example.com/updated-cover.jpg", + shareLevel: "public", + courses: [ + { + title: "Updated Course Title 1", + description: "Updated Course Description 1", + publishCourseId: result.courseIds[0], // Use the publishCourseId from the result + statements: [mockData.courses[0].statements[1], mockData.courses[0].statements[0]], + }, + ], + }; + + const updateResult = await updateCoursePack(result.coursePackId, updateInfo); + expect(updateResult).toBeDefined(); + + // Check if the statement order is updated + const statements = await db.query.statement.findMany({ + where: eq(statementSchema.courseId, result.courseIds[0]), // Use the publishCourseId from the result + orderBy: [asc(statementSchema.order)], + }); + + expect(statements).toBeDefined(); + expect(statements.length).toBe(2); + expect(statements[0].english).toBe(mockData.courses[0].statements[1].english); + expect(statements[1].english).toBe(mockData.courses[0].statements[0].english); + }); + }); + }); +}); + +function createCoursePackMockData() { + return { + title: "Advanced English Course Pack", + description: "This course pack is designed for advanced English learners.", + cover: "https://example.com/cover.jpg", + uId: "user123", + shareLevel: "public", + courses: [ + { + title: "Advanced Grammar", + description: "Deep dive into advanced English grammar concepts.", + publishCourseId: "", + statements: [ + { + english: "The quick brown fox jumps over the lazy dog.", + phonetic: "/ðə kwɪk braʊn fɑks dʒʌmps oʊvər ðə leɪzi dɔɡ/", + chinese: "快速的棕色狐狸跳过懒狗。", + }, + { + english: "She sells seashells by the seashore.", + phonetic: "/ʃi sɛlz siːʃɛlz baɪ ðə siːʃɔːr/", + chinese: "她在海边卖贝壳。", + }, + ], + }, + { + title: "Advanced Vocabulary", + description: "Expand your vocabulary with advanced English words.", + publishCourseId: "", + statements: [ + { + english: "He is an erudite scholar with extensive knowledge.", + phonetic: "/hiː ɪz ən ˈɜːr.daɪt ˈskɒl.ər wɪð ɪkˈstɛn.sɪv ˈnɒl.ɪdʒ/", + chinese: "他是一位博学的学者,拥有广泛的知识。", + }, + { + english: "The symposium encompassed a wide range of topics.", + phonetic: "/ðə ˈsɪm.pə.zi.əm ɛn.kʌmp.əsəd ə waɪd reɪndʒ ʌv ˈtɑ.pɪks/", + chinese: "研讨会涵盖了广泛的主题。", + }, + ], + }, + ], + }; +} diff --git a/packages/game-data-sdk/src/db/index.ts b/packages/game-data-sdk/src/db/index.ts new file mode 100644 index 00000000..1766e62e --- /dev/null +++ b/packages/game-data-sdk/src/db/index.ts @@ -0,0 +1,36 @@ +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import type { SchemaType } from "@earthworm/schema"; +import { schemas } from "@earthworm/schema"; + +export type DbType = PostgresJsDatabase; + +// eslint-disable-next-line import/no-mutable-exports +export let db: DbType; +let connection: postgres.Sql; + +export const setupDB = async (databaseURL: string) => { + connection = postgres(databaseURL || ""); + + db = drizzle(connection, { + schema: schemas, + }); + + return db; +}; + +export async function cleanDB(db: DbType) { + await db.execute( + sql`TRUNCATE TABLE courses, statements, "course_packs" , "user_course_progress", "course_history", "user_learn_record", "memberships" RESTART IDENTITY CASCADE;`, + ); +} + +export async function teardownDb() { + if (connection) { + await connection.end(); + } +} diff --git a/packages/game-data-sdk/src/index.ts b/packages/game-data-sdk/src/index.ts new file mode 100644 index 00000000..32a81b69 --- /dev/null +++ b/packages/game-data-sdk/src/index.ts @@ -0,0 +1,12 @@ +import { setupDB } from "./db"; + +export * from "./course-pack/course-pack.model"; +export * from "./course-pack/course-pack.service"; +export * from "./membership/membership.service"; + +interface Options { + dataBaseURL: string; +} +export function setupGameDataSDK(options: Options) { + setupDB(options.dataBaseURL); +} diff --git a/packages/game-data-sdk/src/membership/membership.service.ts b/packages/game-data-sdk/src/membership/membership.service.ts new file mode 100644 index 00000000..731e80ed --- /dev/null +++ b/packages/game-data-sdk/src/membership/membership.service.ts @@ -0,0 +1,16 @@ +import { eq } from "drizzle-orm"; + +import { membership as membershipSchema } from "@earthworm/schema"; +import { db } from "../db"; + +export async function checkMembership(userId: string) { + const membershipEntity = await db.query.membership.findFirst({ + where: eq(membershipSchema.userId, userId), + }); + + const isActive = membershipEntity ? !!membershipEntity.isActive : false; + return { + isActive, + endDate: membershipEntity ? membershipEntity.end_date : null, + }; +} diff --git a/packages/game-data-sdk/tsconfig.json b/packages/game-data-sdk/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/game-data-sdk/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/game-data-sdk/tsup.config.ts b/packages/game-data-sdk/tsup.config.ts new file mode 100644 index 00000000..e2b649fa --- /dev/null +++ b/packages/game-data-sdk/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + splitting: false, + sourcemap: true, + clean: true, + format: ["cjs", "esm"], + dts: true, +}); diff --git a/packages/game-data-sdk/vitest.config.ts b/packages/game-data-sdk/vitest.config.ts new file mode 100644 index 00000000..1da16d5e --- /dev/null +++ b/packages/game-data-sdk/vitest.config.ts @@ -0,0 +1,17 @@ +import path from "path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "~": path.resolve(__dirname, "src"), + }, + }, + + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/packages/game-data-sdk/vitest.setup.ts b/packages/game-data-sdk/vitest.setup.ts new file mode 100644 index 00000000..9a37477a --- /dev/null +++ b/packages/game-data-sdk/vitest.setup.ts @@ -0,0 +1,13 @@ +import { afterAll, beforeAll } from "vitest"; + +import { setupDB, teardownDb } from "./src/db"; + +beforeAll(async () => { + // 创建连接数据库 + console.log(`setup db ${process.env.DATABASE_URL}`); + await setupDB(process.env.DATABASE_URL || ""); +}); + +afterAll(async () => { + await teardownDb(); +});