diff --git a/AGENTS.md b/AGENTS.md
index 42dd3f8..45cfbf8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,6 +11,10 @@
- `apps/frontend`: Web interface (Not yet implemented)
- `packages/db`: Prisma schema and generated database client
- `packages/core`: Shared business logic, DTOs, and Zod schemas
+- `packages/embedding`: A standalone embedding REST service
+- `packages/logger`: The application logger setup
+- `packages/observability`: Observability configurations
+- `packages/env`: Package for managing environment variables
## 2. Core Mental Model & Architecture
@@ -68,17 +72,10 @@ These rules are absolute. Violation will cause pipeline or runtime failures.
- **camelCase**: Functions (`getUserById`), Variables, and standard file names (`authHandler.ts`).
- **UPPER_SNAKE_CASE**: Constants (`MAX_RATE_LIMIT`).
-### Import Hierarchy
-Order imports from farthest to closest. Prioritize path aliases
-1. External: Built-in and external NPM packages.
-2. Workspace: Internal packages (`@cvsa/core`, `@cvsa/db`).
-3. Aliases: `@/*`, `@modules/*`, `@common/*` pointing to `./src/`.
-4. **[PROHIBITED]**: relative paths (`../`, `./`, `../../../`, etc.)
-
### Module-Specific Information
- **Backend**:
1. Our endpoints starts with `/v2` and this prefix is already configured in root handler. In all of the rest handlers, always use the complete path **without** the version prefix (e.g. `/song/:id/details`)
- 2. **[PROHIBITED]**: DO NOT use verbs in paths. Endpoints should strictly follows the RESTful style.
+ 2. **[PROHIBITED]**: DO NOT use verbs in paths. Endpoints should strictly follows the RESTful pattern.
## 5. Execution Commands & Testing
diff --git a/apps/backend/bunfig.toml b/apps/backend/bunfig.toml
index 3fc94a4..4856355 100644
--- a/apps/backend/bunfig.toml
+++ b/apps/backend/bunfig.toml
@@ -10,4 +10,6 @@ coveragePathIgnorePatterns = [
"src/utils/randomId.ts",
"src/types/**",
"**/observability/src/**",
+ "**/packages/core/**",
+ "src/*.ts"
]
\ No newline at end of file
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 4f0f331..c0e1eb8 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -6,8 +6,8 @@
"lint": "bunx --bun biome lint",
"dev": "NODE_ENV=development bun --watch src/index.ts",
"start": "bun src/index.ts",
- "test": "NODE_ENV=test bun test",
- "test:coverage": "NODE_ENV=test bun test --coverage",
+ "test": "bun test --concurrent",
+ "test:coverage": "bun test --coverage --concurrent",
"typecheck": "bunx --bun tsc --noEmit"
},
"devDependencies": {
diff --git a/apps/backend/src/common/error/ConflictError.ts b/apps/backend/src/common/error/ConflictError.ts
deleted file mode 100644
index d93345e..0000000
--- a/apps/backend/src/common/error/ConflictError.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { AppError } from "@cvsa/core";
-
-export class ConflictError extends AppError {
- constructor(
- field: string,
- code: string = "APP_CONFLICT_ERROR",
- message = "Record already exists",
- statusCode: number = 409
- ) {
- super(message, code, statusCode, { field });
- }
-}
diff --git a/apps/backend/src/common/error/index.ts b/apps/backend/src/common/error/index.ts
index 0bddc90..14ae090 100644
--- a/apps/backend/src/common/error/index.ts
+++ b/apps/backend/src/common/error/index.ts
@@ -1,3 +1,2 @@
-export * from "./ConflictError";
export * from "./RateLimitError";
export * from "./getErrorResponse";
diff --git a/apps/backend/src/errorHandler.ts b/apps/backend/src/errorHandler.ts
index f1600ec..69728ef 100644
--- a/apps/backend/src/errorHandler.ts
+++ b/apps/backend/src/errorHandler.ts
@@ -8,11 +8,6 @@ import { i18nMiddleware } from "@/middlewares";
import { ip } from "elysia-ip";
import { formatGinLog, getLogLevelForRequest } from "@common/log";
-const AUTH_CONFLICT_CODES = [
- "USERNAME_IS_ALREADY_TAKEN",
- "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
-] as const;
-
const AUTH_INVALID_CODES = [
"INVALID_CREDENTIALS",
"INVALID_EMAIL_OR_PASSWORD",
@@ -20,7 +15,6 @@ const AUTH_INVALID_CODES = [
"EMAIL_NOT_VERIFIED",
] as const;
-type AuthConflictCode = (typeof AUTH_CONFLICT_CODES)[number];
type AuthInvalidCode = (typeof AUTH_INVALID_CODES)[number];
interface ErrorStoreData {
diff --git a/apps/backend/src/handlers/catalog/song/create.ts b/apps/backend/src/handlers/catalog/song/create.ts
index 23ca1fb..b5fc7e1 100644
--- a/apps/backend/src/handlers/catalog/song/create.ts
+++ b/apps/backend/src/handlers/catalog/song/create.ts
@@ -1,7 +1,11 @@
import { Elysia } from "elysia";
-import { CreateSongRequestSchema, ErrorResponseSchema, songService } from "@cvsa/core";
+import {
+ CreateSongRequestSchema,
+ ErrorResponseSchema,
+ SongResponseSchema,
+ songService,
+} from "@cvsa/core";
import { authMiddleware } from "@/middlewares";
-import { SongSchema } from "@cvsa/db";
import { traceTask } from "@/common/trace";
export const songCreateHandler = new Elysia({ name: "songCreateHandler" }).use(authMiddleware).post(
@@ -19,7 +23,7 @@ export const songCreateHandler = new Elysia({ name: "songCreateHandler" }).use(a
description: "Create a new song entry in the catalog. Requires authentication.",
},
response: {
- 201: SongSchema,
+ 201: SongResponseSchema,
401: ErrorResponseSchema,
},
}
diff --git a/apps/backend/src/handlers/catalog/song/index.ts b/apps/backend/src/handlers/catalog/song/index.ts
index adfa5af..dc9e11c 100644
--- a/apps/backend/src/handlers/catalog/song/index.ts
+++ b/apps/backend/src/handlers/catalog/song/index.ts
@@ -4,10 +4,12 @@ import { songUpdateHandler } from "./update";
import { songDeleteHandler } from "./delete";
import { songDetailsHandler } from "./details";
import { songSearchHandler } from "./search";
+import { songLyricsHandler } from "./lyrics";
export const songHandler = new Elysia({ name: "songHandler" })
.use(songDetailsHandler)
.use(songCreateHandler)
.use(songUpdateHandler)
.use(songDeleteHandler)
- .use(songSearchHandler);
+ .use(songSearchHandler)
+ .use(songLyricsHandler);
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/create.ts b/apps/backend/src/handlers/catalog/song/lyrics/create.ts
new file mode 100644
index 0000000..33573ea
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/create.ts
@@ -0,0 +1,38 @@
+import { Elysia } from "elysia";
+import {
+ ErrorResponseSchema,
+ SongLyricsCreateRequestSchema,
+ SongLyricsResponseSchema,
+ songService,
+} from "@cvsa/core";
+import { authMiddleware } from "@/middlewares";
+import { traceTask } from "@/common/trace";
+import z from "zod";
+
+export const songLyricsCreateHandler = new Elysia({ name: "songLyricsCreateHandler" })
+ .use(authMiddleware)
+ .post(
+ "/song/:id/lyric",
+ async ({ body, params, status }) => {
+ const lyric = await traceTask("songService.createLyric", async () => {
+ return await songService.createLyric(params.id, body);
+ });
+ return status(201, lyric);
+ },
+ {
+ body: SongLyricsCreateRequestSchema,
+ detail: {
+ summary: "Create Song Lyric",
+ description: "Add a new lyrics item for a song. Requires authentication.",
+ },
+ response: {
+ 201: SongLyricsResponseSchema,
+ 400: ErrorResponseSchema,
+ 401: ErrorResponseSchema,
+ 404: ErrorResponseSchema,
+ },
+ params: z.object({
+ id: z.coerce.number().int().positive(),
+ }),
+ }
+ );
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/delete.ts b/apps/backend/src/handlers/catalog/song/lyrics/delete.ts
new file mode 100644
index 0000000..126e19f
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/delete.ts
@@ -0,0 +1,34 @@
+import { Elysia } from "elysia";
+import { ErrorResponseSchema, songService } from "@cvsa/core";
+import { authMiddleware } from "@/middlewares";
+import { traceTask } from "@/common/trace";
+import z from "zod";
+
+export const songLyricsDeleteHandler = new Elysia({ name: "songLyricsDeleteHandler" })
+ .use(authMiddleware)
+ .delete(
+ "/song/:id/lyric/:lyricId",
+ async ({ params, set }) => {
+ await traceTask("songService.deleteLyric", async () => {
+ return await songService.deleteLyric(params.id, params.lyricId);
+ });
+ set.status = 204;
+ return null;
+ },
+ {
+ detail: {
+ summary: "Delete Song Lyric",
+ description: "Delete a specific lyrics item for a song. Requires authentication.",
+ },
+ response: {
+ 204: z.null(),
+ 400: ErrorResponseSchema,
+ 401: ErrorResponseSchema,
+ 404: ErrorResponseSchema,
+ },
+ params: z.object({
+ id: z.coerce.number().int().positive(),
+ lyricId: z.coerce.number().int().positive(),
+ }),
+ }
+ );
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/get.ts b/apps/backend/src/handlers/catalog/song/lyrics/get.ts
new file mode 100644
index 0000000..97d6a39
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/get.ts
@@ -0,0 +1,29 @@
+import { Elysia } from "elysia";
+import { ErrorResponseSchema, SongLyricsResponseSchema, songService } from "@cvsa/core";
+import z from "zod";
+import { traceTask } from "@/common/trace";
+
+export const songLyricsGetHandler = new Elysia().get(
+ "/song/:id/lyric/:lyricId",
+ async ({ params, status }) => {
+ const lyric = await traceTask("songService.getLyric", async () => {
+ return await songService.getLyric(params.id, params.lyricId);
+ });
+ return status(200, lyric);
+ },
+ {
+ detail: {
+ summary: "Get Song Lyric",
+ description: "Retrieve a specific lyrics item for a song by its ID.",
+ },
+ response: {
+ 200: SongLyricsResponseSchema,
+ 400: ErrorResponseSchema,
+ 404: ErrorResponseSchema,
+ },
+ params: z.object({
+ id: z.coerce.number().int().positive(),
+ lyricId: z.coerce.number().int().positive(),
+ }),
+ }
+);
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/index.ts b/apps/backend/src/handlers/catalog/song/lyrics/index.ts
new file mode 100644
index 0000000..21b4a7e
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/index.ts
@@ -0,0 +1,13 @@
+import { Elysia } from "elysia";
+import { songLyricsListHandler } from "./list";
+import { songLyricsGetHandler } from "./get";
+import { songLyricsCreateHandler } from "./create";
+import { songLyricsUpdateHandler } from "./update";
+import { songLyricsDeleteHandler } from "./delete";
+
+export const songLyricsHandler = new Elysia({ name: "songLyricsHandler" })
+ .use(songLyricsListHandler)
+ .use(songLyricsGetHandler)
+ .use(songLyricsCreateHandler)
+ .use(songLyricsUpdateHandler)
+ .use(songLyricsDeleteHandler);
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/list.ts b/apps/backend/src/handlers/catalog/song/lyrics/list.ts
new file mode 100644
index 0000000..3444506
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/list.ts
@@ -0,0 +1,28 @@
+import { Elysia } from "elysia";
+import { ErrorResponseSchema, SongLyricsListResponseSchema, songService } from "@cvsa/core";
+import z from "zod";
+import { traceTask } from "@/common/trace";
+
+export const songLyricsListHandler = new Elysia().get(
+ "/song/:id/lyrics",
+ async ({ params, status }) => {
+ const lyrics = await traceTask("songService.listLyrics", async () => {
+ return await songService.listLyrics(params.id);
+ });
+ return status(200, lyrics);
+ },
+ {
+ detail: {
+ summary: "List Song Lyrics",
+ description: "Retrieve all available lyrics for a specific song by its ID.",
+ },
+ response: {
+ 200: SongLyricsListResponseSchema,
+ 400: ErrorResponseSchema,
+ 404: ErrorResponseSchema,
+ },
+ params: z.object({
+ id: z.coerce.number().int().positive(),
+ }),
+ }
+);
diff --git a/apps/backend/src/handlers/catalog/song/lyrics/update.ts b/apps/backend/src/handlers/catalog/song/lyrics/update.ts
new file mode 100644
index 0000000..3d0f6ce
--- /dev/null
+++ b/apps/backend/src/handlers/catalog/song/lyrics/update.ts
@@ -0,0 +1,39 @@
+import { Elysia } from "elysia";
+import {
+ ErrorResponseSchema,
+ SongLyricsUpdateRequestSchema,
+ SongLyricsResponseSchema,
+ songService,
+} from "@cvsa/core";
+import { authMiddleware } from "@/middlewares";
+import { traceTask } from "@/common/trace";
+import z from "zod";
+
+export const songLyricsUpdateHandler = new Elysia({ name: "songLyricsUpdateHandler" })
+ .use(authMiddleware)
+ .patch(
+ "/song/:id/lyric/:lyricId",
+ async ({ body, params, status }) => {
+ const lyric = await traceTask("songService.updateLyric", async () => {
+ return await songService.updateLyric(params.id, params.lyricId, body);
+ });
+ return status(200, lyric);
+ },
+ {
+ body: SongLyricsUpdateRequestSchema,
+ detail: {
+ summary: "Update Song Lyric",
+ description: "Update a specific lyrics item for a song. Requires authentication.",
+ },
+ response: {
+ 200: SongLyricsResponseSchema,
+ 400: ErrorResponseSchema,
+ 401: ErrorResponseSchema,
+ 404: ErrorResponseSchema,
+ },
+ params: z.object({
+ id: z.coerce.number().int().positive(),
+ lyricId: z.coerce.number().int().positive(),
+ }),
+ }
+ );
diff --git a/apps/backend/src/handlers/catalog/song/update.ts b/apps/backend/src/handlers/catalog/song/update.ts
index 0aa6bcb..34c723b 100644
--- a/apps/backend/src/handlers/catalog/song/update.ts
+++ b/apps/backend/src/handlers/catalog/song/update.ts
@@ -1,8 +1,12 @@
import { Elysia } from "elysia";
import { z } from "zod";
-import { UpdateSongRequestSchema, ErrorResponseSchema, songService } from "@cvsa/core";
+import {
+ UpdateSongRequestSchema,
+ ErrorResponseSchema,
+ songService,
+ SongResponseSchema,
+} from "@cvsa/core";
import { authMiddleware } from "@/middlewares";
-import { SongSchema } from "@cvsa/db";
import { traceTask } from "@/common/trace";
export const songUpdateHandler = new Elysia({ name: "songUpdateHandler" })
@@ -26,7 +30,7 @@ export const songUpdateHandler = new Elysia({ name: "songUpdateHandler" })
"Update metadata of an existing song identified by its ID. Requires authentication.",
},
response: {
- 200: SongSchema,
+ 200: SongResponseSchema,
400: ErrorResponseSchema,
401: ErrorResponseSchema,
404: ErrorResponseSchema,
diff --git a/apps/backend/tests/auth.test.ts b/apps/backend/tests/auth.test.ts
index 20fab02..6a9bb96 100644
--- a/apps/backend/tests/auth.test.ts
+++ b/apps/backend/tests/auth.test.ts
@@ -1,4 +1,4 @@
-import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { treaty } from "@elysiajs/eden";
import { app } from "@/index";
import { prisma } from "@cvsa/db";
diff --git a/apps/backend/tests/songLyrics.test.ts b/apps/backend/tests/songLyrics.test.ts
new file mode 100644
index 0000000..f2eb306
--- /dev/null
+++ b/apps/backend/tests/songLyrics.test.ts
@@ -0,0 +1,564 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { treaty } from "@elysiajs/eden";
+import { app } from "@/index";
+import { prisma } from "@cvsa/db";
+
+const api = treaty(app);
+
+describe("Song Lyrics E2E Tests", () => {
+ beforeAll(async () => {
+ await prisma.$connect();
+ await prisma.session.deleteMany();
+ await prisma.user.deleteMany();
+ await prisma.lyrics.deleteMany();
+ await prisma.song.deleteMany();
+ });
+
+ afterAll(async () => {
+ await prisma.session.deleteMany();
+ await prisma.user.deleteMany();
+ await prisma.lyrics.deleteMany();
+ await prisma.song.deleteMany();
+ await prisma.$disconnect();
+ });
+
+ async function getAuthToken() {
+ const signup = await api.v2.user.post({
+ username: `${Math.random()}`,
+ password: "password123",
+ });
+ return signup.data?.data.token;
+ }
+
+ describe("GET /v2/song/:id/lyrics - List Song Lyrics", () => {
+ test("should return empty array when song has no lyrics", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const { data, status } = await api.v2.song({ id: song.id }).lyrics.get();
+
+ expect(status).toBe(200);
+ expect(data).toEqual([]);
+ });
+
+ test("should return all lyrics for a song", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "第一行歌词",
+ },
+ {
+ language: "en",
+ isTranslated: true,
+ plainText: "First line lyrics",
+ },
+ ],
+ },
+ },
+ });
+
+ const { data, status } = await api.v2.song({ id: song.id }).lyrics.get();
+
+ expect(status).toBe(200);
+ expect(data).toHaveLength(2);
+ expect(data?.[0]).toMatchObject({
+ language: expect.any(String),
+ isTranslated: expect.any(Boolean),
+ });
+ });
+
+ test("should return 404 for nonexistent song", async () => {
+ const { error, status } = await api.v2.song({ id: 99999 }).lyrics.get();
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+
+ test("should not return deleted lyrics", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "第一行歌词",
+ deletedAt: new Date(),
+ },
+ {
+ language: "en",
+ isTranslated: true,
+ plainText: "First line lyrics",
+ },
+ ],
+ },
+ },
+ });
+
+ const { data, status } = await api.v2.song({ id: song.id }).lyrics.get();
+
+ expect(status).toBe(200);
+ expect(data).toHaveLength(1);
+ expect(data?.[0].language).toBe("en");
+ });
+ });
+
+ describe("GET /v2/song/:id/lyric/:lyricId - Get Specific Lyric", () => {
+ test("should return a specific lyric", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "第一行歌词",
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { data, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .get();
+
+ expect(status).toBe(200);
+ expect(data).toMatchObject({
+ id: lyrics.id,
+ language: "zh",
+ isTranslated: false,
+ plainText: "第一行歌词",
+ });
+ });
+
+ test("should return 404 for nonexistent song", async () => {
+ const { error, status } = await api.v2.song({ id: 99999 }).lyric({ lyricId: 1 }).get();
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+
+ test("should return 404 for nonexistent lyric", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: 99999 })
+ .get();
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+
+ test("should return 404 for deleted lyric", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "第一行歌词",
+ deletedAt: new Date(),
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .get();
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+ });
+
+ describe("POST /v2/song/:id/lyric - Create Lyric", () => {
+ test("should create a lyric with authentication", async () => {
+ const token = await getAuthToken();
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const payload = {
+ language: "ja",
+ isTranslated: true,
+ plainText: "日本語の歌詞",
+ lrc: "[00:00.00]日本語の歌詞",
+ ttml: "日本語の歌詞",
+ };
+
+ const { data, status } = await api.v2.song({ id: song.id }).lyric.post(payload, {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ });
+
+ expect(status).toBe(201);
+ expect(data).toMatchObject({
+ id: expect.any(Number),
+ language: "ja",
+ isTranslated: true,
+ plainText: "日本語の歌詞",
+ lrc: "[00:00.00]日本語の歌詞",
+ ttml: "日本語の歌詞",
+ });
+ });
+
+ test("should return 401 without authentication", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const { error, status } = await api.v2.song({ id: song.id }).lyric.post({
+ language: "zh",
+ plainText: "歌词",
+ });
+
+ expect(status).toBe(401);
+ expect(error?.value).toMatchObject({
+ code: "UNAUTHORIZED",
+ });
+ });
+
+ test("should return 404 for nonexistent song", async () => {
+ const token = await getAuthToken();
+
+ const { error, status } = await api.v2.song({ id: 99999 }).lyric.post(
+ {
+ language: "zh",
+ plainText: "歌词",
+ },
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+ });
+
+ describe("PATCH /v2/song/:id/lyric/:lyricId - Update Lyric", () => {
+ test("should update a lyric with authentication", async () => {
+ const token = await getAuthToken();
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "原歌词",
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { data, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .patch(
+ {
+ language: "en",
+ isTranslated: true,
+ plainText: "Updated lyrics",
+ },
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(200);
+ expect(data).toMatchObject({
+ id: lyrics.id,
+ language: "en",
+ isTranslated: true,
+ plainText: "Updated lyrics",
+ });
+ });
+
+ test("should return 401 without authentication", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .patch({
+ plainText: "Updated lyrics",
+ });
+
+ expect(status).toBe(401);
+ expect(error?.value).toMatchObject({
+ code: "UNAUTHORIZED",
+ });
+ });
+
+ test("should return 404 for nonexistent song", async () => {
+ const token = await getAuthToken();
+
+ const { error, status } = await api.v2
+ .song({ id: 99999 })
+ .lyric({ lyricId: 1 })
+ .patch(
+ {
+ plainText: "Updated lyrics",
+ },
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+
+ test("should return 404 for nonexistent lyric", async () => {
+ const token = await getAuthToken();
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: 99999 })
+ .patch(
+ {
+ plainText: "Updated lyrics",
+ },
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+ });
+
+ describe("DELETE /v2/song/:id/lyric/:lyricId - Delete Lyric", () => {
+ test("should soft delete a lyric with authentication", async () => {
+ const token = await getAuthToken();
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .delete(
+ {},
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(204);
+
+ const deletedLyric = await prisma.lyrics.findUnique({
+ where: { id: lyrics.id },
+ });
+ expect(deletedLyric?.deletedAt).not.toBeNull();
+ });
+
+ test("should return 401 without authentication", async () => {
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ lyrics: {
+ create: [
+ {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ },
+ ],
+ },
+ },
+ });
+
+ const lyrics = await prisma.lyrics.findFirst({
+ where: { songId: song.id },
+ });
+ expect(lyrics).not.toBeNull();
+ if (!lyrics) return;
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: lyrics.id })
+ .delete({}, {});
+
+ expect(status).toBe(401);
+ expect(error?.value).toMatchObject({
+ code: "UNAUTHORIZED",
+ });
+ });
+
+ test("should return 404 for nonexistent song", async () => {
+ const token = await getAuthToken();
+
+ const { error, status } = await api.v2
+ .song({ id: 99999 })
+ .lyric({ lyricId: 1 })
+ .delete(
+ {},
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+
+ test("should return 404 for nonexistent lyric", async () => {
+ const token = await getAuthToken();
+ const song = await prisma.song.create({
+ data: {
+ name: "Test Song",
+ type: "ORIGINAL",
+ },
+ });
+
+ const { error, status } = await api.v2
+ .song({ id: song.id })
+ .lyric({ lyricId: 99999 })
+ .delete(
+ {},
+ {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+ expect(status).toBe(404);
+ expect(error?.value).toMatchObject({
+ code: "NOT_FOUND",
+ });
+ });
+ });
+});
diff --git a/locale/modules/backend/messages/en.json b/locale/modules/backend/messages/en.json
index 50e1538..65f64a0 100644
--- a/locale/modules/backend/messages/en.json
+++ b/locale/modules/backend/messages/en.json
@@ -1,6 +1,7 @@
{
"error.unauthorized": "Unauthorized",
"error.song.notfound": "This song does not exist.",
+ "error.lyric.notfound": "This lyric does not exist.",
"error.signup.failed": "Unable to sign up. Please try again in a moment.",
"error.login.failed": "Unable to log in. Please try again in a moment.",
"error.not-found": "The requested resource was not found.",
diff --git a/locale/modules/backend/messages/zh.json b/locale/modules/backend/messages/zh.json
index d80d144..52acc79 100644
--- a/locale/modules/backend/messages/zh.json
+++ b/locale/modules/backend/messages/zh.json
@@ -1,6 +1,7 @@
{
"error.unauthorized": "未登录",
"error.song.notfound": "此歌曲不存在",
+ "error.lyric.notfound": "此歌词不存在",
"error.signup.failed": "很抱歉,暂时无法注册",
"error.login.failed": "很抱歉,暂时无法登录",
"error.not-found": "请求的路径不存在",
diff --git a/packages/core/src/modules/catalog/song/dto.ts b/packages/core/src/modules/catalog/song/dto.ts
index 09fd5d4..6c1372d 100644
--- a/packages/core/src/modules/catalog/song/dto.ts
+++ b/packages/core/src/modules/catalog/song/dto.ts
@@ -6,6 +6,7 @@ import {
ArtistRoleSchema,
SongTypeSchema,
type Serialized,
+ LyricsSchema,
} from "@cvsa/db";
export type SongId = number;
@@ -17,12 +18,20 @@ const CreateCreationSchema = z.object({
const CreatePerformanceSchema = z.object({
singerId: z.int().positive(),
- voicebankId: z.int().positive().nullish(),
- svsEngineId: z.int().positive().nullish(),
- svsEngineVersionId: z.int().positive().nullish(),
+ voicebankId: z.int().positive().optional(),
+ svsEngineId: z.int().positive().optional(),
+ svsEngineVersionId: z.int().positive().optional(),
});
-const CreateLyricsSchema = z.object({
+export const SongLyricsCreateRequestSchema = z.object({
+ language: z.string().optional(),
+ isTranslated: z.boolean().optional(),
+ plainText: z.string().optional(),
+ ttml: z.string().optional(),
+ lrc: z.string().optional(),
+});
+
+export const SongLyricsUpdateRequestSchema = z.object({
language: z.string().optional(),
isTranslated: z.boolean().optional(),
plainText: z.string().optional(),
@@ -31,31 +40,31 @@ const CreateLyricsSchema = z.object({
});
export const CreateSongRequestSchema = z.object({
- name: z.string().nullish(),
- type: SongTypeSchema.nullish(),
- language: z.string().nullish(),
- localizedNames: z.record(z.string(), z.string()).nullish(),
- description: z.string().nullish(),
- localizedDescriptions: z.record(z.string(), z.string()).nullish(),
- duration: z.int().nullish(),
- coverUrl: z.url().nullish(),
- publishedAt: z.iso.datetime().nullish(),
+ name: z.string().optional(),
+ type: SongTypeSchema.optional(),
+ language: z.string().optional(),
+ localizedNames: z.record(z.string(), z.string()).optional(),
+ description: z.string().optional(),
+ localizedDescriptions: z.record(z.string(), z.string()).optional(),
+ duration: z.int().optional(),
+ coverUrl: z.url().optional(),
+ publishedAt: z.iso.datetime().optional(),
performances: z.array(CreatePerformanceSchema).optional(),
creations: z.array(CreateCreationSchema).optional(),
- lyrics: z.array(CreateLyricsSchema).optional(),
+ lyrics: z.array(SongLyricsCreateRequestSchema).optional(),
});
export const UpdateSongRequestSchema = z.object({
- name: z.string().nullish(),
- type: SongTypeSchema.nullish(),
- duration: z.int().nullish(),
- description: z.string().nullish(),
- coverUrl: z.url().nullish(),
- publishedAt: z.iso.datetime().nullish(),
+ name: z.string().optional(),
+ type: SongTypeSchema.optional(),
+ duration: z.int().optional(),
+ description: z.string().optional(),
+ coverUrl: z.url().optional(),
+ publishedAt: z.iso.datetime().optional(),
});
export const SongDetailsResponseSchema = z.intersection(
- SongSchema,
+ SongSchema.omit({ deletedAt: true }),
z.object({
singers: z
.intersection(
@@ -71,16 +80,25 @@ export const SongDetailsResponseSchema = z.intersection(
z.object({ role: ArtistRoleSchema })
)
.array(),
- lyrics: z.array(
- z.object({
- plainText: z.string().nullable(),
- isTranslated: z.boolean(),
- language: z.string().nullable(),
- })
- ),
+ lyrics: LyricsSchema.omit({ deletedAt: true }).array(),
})
);
+export const SongResponseSchema = SongSchema.omit({ deletedAt: true });
+
+export const SongLyricsResponseSchema = LyricsSchema.omit({
+ songId: true,
+ deletedAt: true,
+});
+
+export const SongLyricsListResponseSchema = SongLyricsResponseSchema.array();
+
+export type SongResponseDto = Serialized>;
export type CreateSongRequestDto = Serialized>;
export type UpdateSongRequestDto = Serialized>;
export type SongDetailsResponseDto = Serialized>;
+
+export type SongLyricsResponseDto = Serialized>;
+export type SongLyricsListResponseDto = Serialized>;
+export type SongLyricsCreateRequestDto = Serialized>;
+export type SongLyricsUpdateRequestDto = Serialized>;
diff --git a/packages/core/src/modules/catalog/song/repository.interface.ts b/packages/core/src/modules/catalog/song/repository.interface.ts
index f19022c..be9083f 100644
--- a/packages/core/src/modules/catalog/song/repository.interface.ts
+++ b/packages/core/src/modules/catalog/song/repository.interface.ts
@@ -1,20 +1,38 @@
-import type { Song, TxClient, Serialized } from "@cvsa/db";
+import type { TxClient } from "@cvsa/db";
import type { IRepositoryWithGetDetails } from "@cvsa/core/internal";
import type {
CreateSongRequestDto,
SongId,
UpdateSongRequestDto,
SongDetailsResponseDto,
+ SongResponseDto,
+ SongLyricsCreateRequestDto,
+ SongLyricsUpdateRequestDto,
+ SongLyricsResponseDto,
+ SongLyricsListResponseDto,
} from "./dto";
export abstract class ISongRepository implements IRepositoryWithGetDetails {
- abstract getById(id: SongId, tx?: TxClient): Promise | null>;
+ abstract getById(id: SongId, tx?: TxClient): Promise;
abstract getDetailsById(id: SongId, tx?: TxClient): Promise;
- abstract create(input: CreateSongRequestDto, tx?: TxClient): Promise>;
+ abstract create(input: CreateSongRequestDto, tx?: TxClient): Promise;
abstract update(
id: SongId,
input: UpdateSongRequestDto,
tx?: TxClient
- ): Promise>;
+ ): Promise;
abstract softDelete(id: SongId, tx?: TxClient): Promise;
+ abstract createLyrics(
+ id: SongId,
+ input: SongLyricsCreateRequestDto,
+ tx?: TxClient
+ ): Promise;
+ abstract getLyricsBySongId(id: SongId, tx?: TxClient): Promise;
+ abstract getLyricById(lyricId: number, tx?: TxClient): Promise;
+ abstract updateLyric(
+ lyricId: number,
+ input: SongLyricsUpdateRequestDto,
+ tx?: TxClient
+ ): Promise;
+ abstract softDeleteLyric(lyricId: number, tx?: TxClient): Promise;
}
diff --git a/packages/core/src/modules/catalog/song/repository.ts b/packages/core/src/modules/catalog/song/repository.ts
index 0444a94..7ac77bc 100644
--- a/packages/core/src/modules/catalog/song/repository.ts
+++ b/packages/core/src/modules/catalog/song/repository.ts
@@ -4,6 +4,8 @@ import type {
SongId,
UpdateSongRequestDto,
SongDetailsResponseDto,
+ SongLyricsCreateRequestDto,
+ SongLyricsUpdateRequestDto,
} from "./dto";
import type { ISongRepository } from "./repository.interface";
import { transformPrismaResult, type TxClient } from "@cvsa/db";
@@ -14,7 +16,10 @@ export class SongRepository implements ISongRepository {
async getById(id: SongId, tx?: TxClient) {
const client = tx ?? this.prisma;
return transformPrismaResult(
- await client.song.findFirst({ where: { id, deletedAt: null } })
+ await client.song.findFirst({
+ where: { id, deletedAt: null },
+ omit: { deletedAt: true },
+ })
);
}
@@ -37,7 +42,14 @@ export class SongRepository implements ISongRepository {
artist: true,
},
},
- lyrics: true,
+ lyrics: {
+ omit: {
+ deletedAt: true,
+ },
+ },
+ },
+ omit: {
+ deletedAt: true,
},
})
);
@@ -63,13 +75,7 @@ export class SongRepository implements ISongRepository {
role: item.role,
};
}),
- lyrics: lyrics.map((item) => {
- return {
- plainText: item.plainText,
- language: item.language,
- isTranslated: item.isTranslated,
- };
- }),
+ lyrics: lyrics,
...song,
};
}
@@ -81,15 +87,7 @@ export class SongRepository implements ISongRepository {
return transformPrismaResult(
await client.song.create({
data: {
- language: songData.language ?? undefined,
- type: songData.type ?? null,
- name: songData.name ?? null,
- localizedNames: songData.localizedNames ?? undefined,
- localizedDescriptions: songData.localizedDescriptions ?? undefined,
- duration: songData.duration ?? null,
- description: songData.description ?? null,
- coverUrl: songData.coverUrl ?? null,
- publishedAt: songData.publishedAt ?? null,
+ ...songData,
performances: performances && {
create: performances,
},
@@ -103,6 +101,9 @@ export class SongRepository implements ISongRepository {
create: lyrics,
},
},
+ omit: {
+ deletedAt: true,
+ },
})
);
}
@@ -114,6 +115,9 @@ export class SongRepository implements ISongRepository {
await client.song.update({
where: { id },
data: input,
+ omit: {
+ deletedAt: true,
+ },
})
);
}
@@ -122,4 +126,63 @@ export class SongRepository implements ISongRepository {
const client = tx ?? this.prisma;
await client.song.update({ where: { id }, data: { deletedAt: new Date() } });
}
+
+ async createLyrics(id: SongId, input: SongLyricsCreateRequestDto, tx?: TxClient) {
+ const client = tx ?? this.prisma;
+
+ return transformPrismaResult(
+ await client.lyrics.create({
+ data: {
+ songId: id,
+ ...input,
+ },
+ omit: {
+ deletedAt: true,
+ },
+ })
+ );
+ }
+
+ async getLyricsBySongId(id: SongId, tx?: TxClient) {
+ const client = tx ?? this.prisma;
+
+ return transformPrismaResult(
+ await client.lyrics.findMany({
+ where: { songId: id, deletedAt: null },
+ omit: { deletedAt: true, songId: true },
+ })
+ );
+ }
+
+ async getLyricById(lyricId: number, tx?: TxClient) {
+ const client = tx ?? this.prisma;
+
+ return transformPrismaResult(
+ await client.lyrics.findFirst({
+ where: { id: lyricId, deletedAt: null },
+ omit: { deletedAt: true, songId: true },
+ })
+ );
+ }
+
+ async updateLyric(lyricId: number, input: SongLyricsUpdateRequestDto, tx?: TxClient) {
+ const client = tx ?? this.prisma;
+
+ return transformPrismaResult(
+ await client.lyrics.update({
+ where: { id: lyricId },
+ data: input,
+ omit: { deletedAt: true, songId: true },
+ })
+ );
+ }
+
+ async softDeleteLyric(lyricId: number, tx?: TxClient) {
+ const client = tx ?? this.prisma;
+
+ await client.lyrics.update({
+ where: { id: lyricId },
+ data: { deletedAt: new Date() },
+ });
+ }
}
diff --git a/packages/core/src/modules/catalog/song/service.ts b/packages/core/src/modules/catalog/song/service.ts
index 7a62940..ec58fa1 100644
--- a/packages/core/src/modules/catalog/song/service.ts
+++ b/packages/core/src/modules/catalog/song/service.ts
@@ -1,11 +1,15 @@
import type { SongSearchService } from "@cvsa/core/internal";
import { AppError, type IServiceWithGetDetails } from "@cvsa/core/internal";
-import type { Song, Serialized } from "@cvsa/db";
import type {
SongDetailsResponseDto,
SongId,
CreateSongRequestDto,
UpdateSongRequestDto,
+ SongResponseDto,
+ SongLyricsCreateRequestDto,
+ SongLyricsUpdateRequestDto,
+ SongLyricsResponseDto,
+ SongLyricsListResponseDto,
} from "./dto";
import type { ISongRepository } from "./repository.interface";
import { traceTask } from "@cvsa/observability";
@@ -27,7 +31,7 @@ export class SongService implements IServiceWithGetDetails> {
+ async create(input: CreateSongRequestDto): Promise {
const result = await traceTask("db create song", async () => {
return await this.repository.create(input);
});
@@ -39,7 +43,7 @@ export class SongService implements IServiceWithGetDetails> {
+ async update(id: SongId, input: UpdateSongRequestDto): Promise {
const existing = await this.repository.getById(id);
if (existing === null) {
throw new AppError("error.song.notfound", "NOT_FOUND", 404);
@@ -70,4 +74,73 @@ export class SongService implements IServiceWithGetDetails {
+ const existing = await this.repository.getById(id);
+ if (existing === null) {
+ throw new AppError("error.song.notfound", "NOT_FOUND", 404);
+ }
+ return traceTask("db list lyrics", async () => {
+ return await this.repository.getLyricsBySongId(id);
+ });
+ }
+
+ async getLyric(id: SongId, lyricId: number): Promise {
+ const existing = await this.repository.getById(id);
+ if (existing === null) {
+ throw new AppError("error.song.notfound", "NOT_FOUND", 404);
+ }
+ const lyric = await traceTask("db get lyric", async () => {
+ return await this.repository.getLyricById(lyricId);
+ });
+ if (lyric === null) {
+ throw new AppError("error.lyric.notfound", "NOT_FOUND", 404);
+ }
+ return lyric;
+ }
+
+ async createLyric(
+ id: SongId,
+ input: SongLyricsCreateRequestDto
+ ): Promise {
+ const existing = await this.repository.getById(id);
+ if (existing === null) {
+ throw new AppError("error.song.notfound", "NOT_FOUND", 404);
+ }
+ return traceTask("db create lyric", async () => {
+ return await this.repository.createLyrics(id, input);
+ });
+ }
+
+ async updateLyric(
+ id: SongId,
+ lyricId: number,
+ input: SongLyricsUpdateRequestDto
+ ): Promise {
+ const existing = await this.repository.getById(id);
+ if (existing === null) {
+ throw new AppError("error.song.notfound", "NOT_FOUND", 404);
+ }
+ const lyric = await this.repository.getLyricById(lyricId);
+ if (lyric === null) {
+ throw new AppError("error.lyric.notfound", "NOT_FOUND", 404);
+ }
+ return traceTask("db update lyric", async () => {
+ return await this.repository.updateLyric(lyricId, input);
+ });
+ }
+
+ async deleteLyric(id: SongId, lyricId: number): Promise {
+ const existing = await this.repository.getById(id);
+ if (existing === null) {
+ throw new AppError("error.song.notfound", "NOT_FOUND", 404);
+ }
+ const lyric = await this.repository.getLyricById(lyricId);
+ if (lyric === null) {
+ throw new AppError("error.lyric.notfound", "NOT_FOUND", 404);
+ }
+ await traceTask("db delete lyric", async () => {
+ return await this.repository.softDeleteLyric(lyricId);
+ });
+ }
}
diff --git a/packages/core/tests/integration/SongRepository.test.ts b/packages/core/tests/integration/SongRepository.test.ts
index ee146c3..395aedc 100644
--- a/packages/core/tests/integration/SongRepository.test.ts
+++ b/packages/core/tests/integration/SongRepository.test.ts
@@ -47,7 +47,8 @@ describe("SongRepository Integration Tests", () => {
expect(result.duration).toBe(180);
expect(result.description).toBe("A test song");
expect(result.coverUrl).toBe("https://example.com/cover.jpg");
- expect(result.deletedAt).toBeNull();
+ //@ts-expect-error accessing nonexistent field for testing purpose
+ expect(result.deletedAt).not.toBeDefined();
});
test("should create a song with minimal fields", async () => {
@@ -73,6 +74,8 @@ describe("SongRepository Integration Tests", () => {
expect(result).toBeDefined();
expect(result?.id).toBe(created.id);
expect(result?.name).toBe("Find Me Song");
+ //@ts-expect-error accessing nonexistent field for testing purpose
+ expect(result?.deletedAt).not.toBeDefined();
});
test("should return null when song does not exist", async () => {
@@ -134,6 +137,8 @@ describe("SongRepository Integration Tests", () => {
name: "作曲",
},
});
+ //@ts-expect-error accessing nonexistent field for testing purpose
+ expect(result?.deletedAt).not.toBeDefined();
});
test("should return null when song does not exist", async () => {
@@ -157,6 +162,8 @@ describe("SongRepository Integration Tests", () => {
expect(result.name).toBe("New Name");
expect(result.type).toBe("COVER");
expect(result.duration).toBe(200);
+ //@ts-expect-error accessing nonexistent field for testing purpose
+ expect(result.deletedAt).not.toBeDefined();
});
test("should update only specified fields", async () => {
@@ -184,4 +191,189 @@ describe("SongRepository Integration Tests", () => {
expect(result).toBeNull();
});
});
+
+ describe("createLyrics", () => {
+ test("should create lyrics for a song", async () => {
+ const song = await repository.create({ name: "Song with Lyrics" });
+
+ const lyric = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词内容",
+ });
+
+ expect(lyric).toBeDefined();
+ expect(lyric.id).toBeGreaterThan(0);
+ expect(lyric.language).toBe("zh");
+ expect(lyric.isTranslated).toBe(false);
+ expect(lyric.plainText).toBe("歌词内容");
+ expect(lyric.ttml).toBeNull();
+ expect(lyric.lrc).toBeNull();
+ //@ts-expect-error accessing nonexistent field for testing purpose
+ expect(lyric.deletedAt).not.toBeDefined();
+ });
+
+ test("should create lyrics with optional fields", async () => {
+ const song = await repository.create({ name: "Song with Full Lyrics" });
+
+ const lyric = await repository.createLyrics(song.id, {
+ language: "ja",
+ isTranslated: true,
+ plainText: "日本語の歌詞",
+ ttml: "test",
+ lrc: "[00:00.00]test",
+ });
+
+ expect(lyric.language).toBe("ja");
+ expect(lyric.isTranslated).toBe(true);
+ expect(lyric.plainText).toBe("日本語の歌詞");
+ expect(lyric.ttml).toBe("test");
+ expect(lyric.lrc).toBe("[00:00.00]test");
+ });
+ });
+
+ describe("getLyricsBySongId", () => {
+ test("should return all lyrics for a song", async () => {
+ const song = await repository.create({
+ name: "Song with Multiple Lyrics",
+ });
+
+ await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "中文歌词",
+ });
+ await repository.createLyrics(song.id, {
+ language: "en",
+ isTranslated: true,
+ plainText: "English lyrics",
+ });
+
+ const lyrics = await repository.getLyricsBySongId(song.id);
+
+ expect(lyrics).toHaveLength(2);
+ expect(lyrics.map((l) => l.language)).toContain("zh");
+ expect(lyrics.map((l) => l.language)).toContain("en");
+ });
+
+ test("should return empty array when song has no lyrics", async () => {
+ const song = await repository.create({ name: "Song without Lyrics" });
+
+ const lyrics = await repository.getLyricsBySongId(song.id);
+
+ expect(lyrics).toEqual([]);
+ });
+
+ test("should not return deleted lyrics", async () => {
+ const song = await repository.create({ name: "Song with Deleted Lyrics" });
+
+ const lyric = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "将被删除的歌词",
+ });
+ await repository.softDeleteLyric(lyric.id);
+
+ const lyrics = await repository.getLyricsBySongId(song.id);
+
+ expect(lyrics).toHaveLength(0);
+ });
+ });
+
+ describe("getLyricById", () => {
+ test("should return lyric when exists", async () => {
+ const song = await repository.create({ name: "Song" });
+ const created = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ });
+
+ const lyric = await repository.getLyricById(created.id);
+
+ expect(lyric).toBeDefined();
+ expect(lyric?.id).toBe(created.id);
+ expect(lyric?.plainText).toBe("歌词");
+ });
+
+ test("should return null when lyric does not exist", async () => {
+ const lyric = await repository.getLyricById(999999);
+
+ expect(lyric).toBeNull();
+ });
+
+ test("should return null when lyric is deleted", async () => {
+ const song = await repository.create({ name: "Song" });
+ const created = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ });
+ await repository.softDeleteLyric(created.id);
+
+ const lyric = await repository.getLyricById(created.id);
+
+ expect(lyric).toBeNull();
+ });
+ });
+
+ describe("updateLyric", () => {
+ test("should update all fields", async () => {
+ const song = await repository.create({ name: "Song" });
+ const created = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "原歌词",
+ });
+
+ const updated = await repository.updateLyric(created.id, {
+ language: "en",
+ isTranslated: true,
+ plainText: "Updated lyrics",
+ ttml: "updated",
+ lrc: "[00:00.00]updated",
+ });
+
+ expect(updated.language).toBe("en");
+ expect(updated.isTranslated).toBe(true);
+ expect(updated.plainText).toBe("Updated lyrics");
+ expect(updated.ttml).toBe("updated");
+ expect(updated.lrc).toBe("[00:00.00]updated");
+ });
+
+ test("should update only specified fields", async () => {
+ const song = await repository.create({ name: "Song" });
+ const created = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "原歌词",
+ ttml: "original",
+ });
+
+ const updated = await repository.updateLyric(created.id, {
+ plainText: "新歌词",
+ });
+
+ expect(updated.plainText).toBe("新歌词");
+ expect(updated.language).toBe("zh");
+ expect(updated.isTranslated).toBe(false);
+ expect(updated.ttml).toBe("original");
+ });
+ });
+
+ describe("softDeleteLyric", () => {
+ test("should soft delete a lyric", async () => {
+ const song = await repository.create({ name: "Song" });
+ const created = await repository.createLyrics(song.id, {
+ language: "zh",
+ isTranslated: false,
+ plainText: "将被删除",
+ });
+
+ await repository.softDeleteLyric(created.id);
+
+ const lyric = await repository.getLyricById(created.id);
+ expect(lyric).toBeNull();
+ });
+ });
});
diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts
index 3da6704..29fb055 100644
--- a/packages/core/tests/unit/SongSearchService.test.ts
+++ b/packages/core/tests/unit/SongSearchService.test.ts
@@ -44,7 +44,6 @@ const mockSongDetails: SongDetailsResponseDto = {
description: "A test song",
coverUrl: "https://example.com/cover.jpg",
publishedAt: new Date("2024-01-01").toISOString(),
- deletedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
originalSongId: null,
@@ -96,11 +95,23 @@ const mockSongDetails: SongDetailsResponseDto = {
language: "zh",
plainText: "歌词内容",
isTranslated: false,
+ ttml: "",
+ lrc: "",
+ id: 1,
+ createdAt: "",
+ updatedAt: "",
+ songId: 1,
},
{
language: "en",
plainText: "Lyrics content",
isTranslated: true,
+ ttml: "",
+ lrc: "",
+ id: 1,
+ createdAt: "",
+ updatedAt: "",
+ songId: 1,
},
],
};
diff --git a/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts
index f2f142b..370a328 100644
--- a/packages/core/tests/unit/SongService.test.ts
+++ b/packages/core/tests/unit/SongService.test.ts
@@ -12,7 +12,6 @@ const mockSongDetails: SongDetailsResponseDto = {
description: "A test song",
coverUrl: "https://example.com/cover.jpg",
publishedAt: new Date("2024-01-01").toISOString(),
- deletedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
originalSongId: null,
@@ -45,6 +44,29 @@ describe("SongService", () => {
create: async () => mockSongDetails,
update: async () => mockSongDetails,
softDelete: async () => {},
+ createLyrics: async () => ({
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "Test lyrics",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }),
+ getLyricsBySongId: async () => [],
+ getLyricById: async () => null,
+ updateLyric: async () => ({
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "Updated lyrics",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ }),
+ softDeleteLyric: async () => {},
});
const mockSearchService = {
@@ -181,4 +203,168 @@ describe("SongService", () => {
expect(mockRepository.softDelete).toHaveBeenCalledWith(1);
});
});
+
+ describe("listLyrics", () => {
+ test("returns lyrics when song exists", async () => {
+ mockRepository.getLyricsBySongId.mockResolvedValueOnce([
+ {
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ]);
+
+ const result = await songService.listLyrics(1);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].language).toBe("zh");
+ expect(mockRepository.getLyricsBySongId).toHaveBeenCalledWith(1);
+ });
+
+ test("throws NOT_FOUND error when song does not exist", async () => {
+ mockRepository.getById.mockResolvedValueOnce(null);
+
+ expect(songService.listLyrics(999)).rejects.toThrow(AppError);
+ expect(songService.listLyrics(999)).rejects.toThrow("error.song.notfound");
+ });
+ });
+
+ describe("getLyric", () => {
+ test("returns lyric when song and lyric exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce({
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+
+ const result = await songService.getLyric(1, 1);
+
+ expect(result.id).toBe(1);
+ expect(result.plainText).toBe("歌词");
+ });
+
+ test("throws NOT_FOUND error when song does not exist", async () => {
+ mockRepository.getById.mockResolvedValueOnce(null);
+
+ expect(songService.getLyric(999, 1)).rejects.toThrow(AppError);
+ expect(songService.getLyric(999, 1)).rejects.toThrow("error.song.notfound");
+ });
+
+ test("throws NOT_FOUND error when lyric does not exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce(null);
+
+ expect(songService.getLyric(1, 999)).rejects.toThrow(AppError);
+ expect(songService.getLyric(1, 999)).rejects.toThrow("error.lyric.notfound");
+ });
+ });
+
+ describe("createLyric", () => {
+ const createInput = {
+ language: "ja" as const,
+ isTranslated: true,
+ plainText: "日本語の歌詞",
+ };
+
+ test("creates lyric when song exists", async () => {
+ const result = await songService.createLyric(1, createInput);
+
+ expect(result).toMatchObject({
+ language: "zh",
+ plainText: "Test lyrics",
+ });
+ expect(mockRepository.createLyrics).toHaveBeenCalledWith(1, createInput);
+ });
+
+ test("throws NOT_FOUND error when song does not exist", async () => {
+ mockRepository.getById.mockResolvedValueOnce(null);
+
+ expect(songService.createLyric(999, createInput)).rejects.toThrow(AppError);
+ expect(songService.createLyric(999, createInput)).rejects.toThrow(
+ "error.song.notfound"
+ );
+ });
+ });
+
+ describe("updateLyric", () => {
+ const updateInput = { plainText: "Updated lyrics" };
+
+ test("updates lyric when song and lyric exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce({
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "原歌词",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+
+ const result = await songService.updateLyric(1, 1, updateInput);
+
+ expect(result.plainText).toBe("Updated lyrics");
+ expect(mockRepository.updateLyric).toHaveBeenCalledWith(1, updateInput);
+ });
+
+ test("throws NOT_FOUND error when song does not exist", async () => {
+ mockRepository.getById.mockResolvedValueOnce(null);
+
+ expect(songService.updateLyric(999, 1, updateInput)).rejects.toThrow(AppError);
+ expect(songService.updateLyric(999, 1, updateInput)).rejects.toThrow(
+ "error.song.notfound"
+ );
+ });
+
+ test("throws NOT_FOUND error when lyric does not exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce(null);
+
+ expect(songService.updateLyric(1, 999, updateInput)).rejects.toThrow(AppError);
+ expect(songService.updateLyric(1, 999, updateInput)).rejects.toThrow(
+ "error.lyric.notfound"
+ );
+ });
+ });
+
+ describe("deleteLyric", () => {
+ test("soft deletes lyric when song and lyric exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce({
+ id: 1,
+ language: "zh",
+ isTranslated: false,
+ plainText: "歌词",
+ ttml: null,
+ lrc: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+
+ await songService.deleteLyric(1, 1);
+
+ expect(mockRepository.softDeleteLyric).toHaveBeenCalledWith(1);
+ });
+
+ test("throws NOT_FOUND error when song does not exist", async () => {
+ mockRepository.getById.mockResolvedValueOnce(null);
+
+ expect(songService.deleteLyric(999, 1)).rejects.toThrow(AppError);
+ expect(songService.deleteLyric(999, 1)).rejects.toThrow("error.song.notfound");
+ });
+
+ test("throws NOT_FOUND error when lyric does not exist", async () => {
+ mockRepository.getLyricById.mockResolvedValueOnce(null);
+
+ expect(songService.deleteLyric(1, 999)).rejects.toThrow(AppError);
+ expect(songService.deleteLyric(1, 999)).rejects.toThrow("error.lyric.notfound");
+ });
+ });
});