From 1a37ab58278087f3f61fd6c430f9d16c8457ba73 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 11 Apr 2026 18:29:30 +0800 Subject: [PATCH 1/6] fix: remove `deletedAt` from song response --- apps/backend/src/errorHandler.ts | 6 ------ apps/backend/src/handlers/catalog/song/create.ts | 10 +++++++--- apps/backend/src/handlers/catalog/song/update.ts | 10 +++++++--- apps/backend/tests/auth.test.ts | 2 +- packages/core/src/modules/catalog/song/dto.ts | 5 ++++- .../src/modules/catalog/song/repository.interface.ts | 9 +++++---- packages/core/src/modules/catalog/song/repository.ts | 3 +++ packages/core/src/modules/catalog/song/service.ts | 6 +++--- packages/core/tests/unit/SongSearchService.test.ts | 1 - packages/core/tests/unit/SongService.test.ts | 1 - 10 files changed, 30 insertions(+), 23 deletions(-) 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/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/packages/core/src/modules/catalog/song/dto.ts b/packages/core/src/modules/catalog/song/dto.ts index 09fd5d4..5a6323b 100644 --- a/packages/core/src/modules/catalog/song/dto.ts +++ b/packages/core/src/modules/catalog/song/dto.ts @@ -55,7 +55,7 @@ export const UpdateSongRequestSchema = z.object({ }); export const SongDetailsResponseSchema = z.intersection( - SongSchema, + SongSchema.omit({ deletedAt: true }), z.object({ singers: z .intersection( @@ -81,6 +81,9 @@ export const SongDetailsResponseSchema = z.intersection( }) ); +export const SongResponseSchema = SongSchema.omit({ deletedAt: true }); + +export type SongResponseDto = Serialized>; export type CreateSongRequestDto = Serialized>; export type UpdateSongRequestDto = Serialized>; export type SongDetailsResponseDto = 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..febfd42 100644 --- a/packages/core/src/modules/catalog/song/repository.interface.ts +++ b/packages/core/src/modules/catalog/song/repository.interface.ts @@ -1,20 +1,21 @@ -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, } 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; } diff --git a/packages/core/src/modules/catalog/song/repository.ts b/packages/core/src/modules/catalog/song/repository.ts index 0444a94..10c15b1 100644 --- a/packages/core/src/modules/catalog/song/repository.ts +++ b/packages/core/src/modules/catalog/song/repository.ts @@ -39,6 +39,9 @@ export class SongRepository implements ISongRepository { }, lyrics: true, }, + omit: { + deletedAt: true, + }, }) ); if (!data) return null; diff --git a/packages/core/src/modules/catalog/song/service.ts b/packages/core/src/modules/catalog/song/service.ts index 7a62940..0310aa0 100644 --- a/packages/core/src/modules/catalog/song/service.ts +++ b/packages/core/src/modules/catalog/song/service.ts @@ -1,11 +1,11 @@ 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, } from "./dto"; import type { ISongRepository } from "./repository.interface"; import { traceTask } from "@cvsa/observability"; @@ -27,7 +27,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 +39,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); diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts index 3da6704..52fbe25 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, diff --git a/packages/core/tests/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index f2f142b..27f356a 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, From 5b5208388a1a9e97d0750de8246fc3dd02c3624d Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 11 Apr 2026 19:38:01 +0800 Subject: [PATCH 2/6] refactor: the DTOs & schemas of song --- AGENTS.md | 13 ++-- packages/core/src/modules/catalog/song/dto.ts | 60 ++++++++++--------- .../core/tests/unit/SongSearchService.test.ts | 12 ++++ 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42dd3f8..77b981b 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 +- `pakcages/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/packages/core/src/modules/catalog/song/dto.ts b/packages/core/src/modules/catalog/song/dto.ts index 5a6323b..35e8ed4 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,12 @@ 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(), @@ -31,27 +32,27 @@ 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( @@ -71,19 +72,24 @@ 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>; diff --git a/packages/core/tests/unit/SongSearchService.test.ts b/packages/core/tests/unit/SongSearchService.test.ts index 52fbe25..29fb055 100644 --- a/packages/core/tests/unit/SongSearchService.test.ts +++ b/packages/core/tests/unit/SongSearchService.test.ts @@ -95,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, }, ], }; From d90e76e56e3af46c4c4e65db3fbc81378fd1c53f Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 11 Apr 2026 19:38:01 +0800 Subject: [PATCH 3/6] feat(song): add song lyrics CRUD endpoints Implement full CRUD operations for song lyrics: - GET /v2/song/:id/lyrics - List all lyrics for a song - GET /v2/song/:id/lyric/:lyricId - Get specific lyric - POST /v2/song/:id/lyric - Create lyric (auth required) - PATCH /v2/song/:id/lyric/:lyricId - Update lyric (auth required) - DELETE /v2/song/:id/lyric/:lyricId - Delete lyric (auth required) Includes repository methods, service layer, DTOs, E2E tests, and i18n translations. --- .../src/handlers/catalog/song/index.ts | 4 +- .../handlers/catalog/song/lyrics/create.ts | 38 ++ .../handlers/catalog/song/lyrics/delete.ts | 34 ++ .../src/handlers/catalog/song/lyrics/get.ts | 29 + .../src/handlers/catalog/song/lyrics/index.ts | 13 + .../src/handlers/catalog/song/lyrics/list.ts | 28 + .../handlers/catalog/song/lyrics/update.ts | 39 ++ apps/backend/tests/songLyrics.test.ts | 564 ++++++++++++++++++ locale/modules/backend/messages/en.json | 1 + locale/modules/backend/messages/zh.json | 1 + packages/core/src/modules/catalog/song/dto.ts | 9 + .../catalog/song/repository.interface.ts | 17 + .../src/modules/catalog/song/repository.ts | 82 ++- .../core/src/modules/catalog/song/service.ts | 73 +++ packages/core/tests/unit/SongService.test.ts | 23 + 15 files changed, 937 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/create.ts create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/delete.ts create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/get.ts create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/index.ts create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/list.ts create mode 100644 apps/backend/src/handlers/catalog/song/lyrics/update.ts create mode 100644 apps/backend/tests/songLyrics.test.ts 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/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 35e8ed4..b89f2f8 100644 --- a/packages/core/src/modules/catalog/song/dto.ts +++ b/packages/core/src/modules/catalog/song/dto.ts @@ -31,6 +31,14 @@ export const SongLyricsCreateRequestSchema = z.object({ lrc: z.string().optional(), }); +export const SongLyricsUpdateRequestSchema = z.object({ + language: z.string().optional(), + isTranslated: z.boolean().optional(), + plainText: z.string().optional(), + ttml: z.string().optional(), + lrc: z.string().optional(), +}); + export const CreateSongRequestSchema = z.object({ name: z.string().optional(), type: SongTypeSchema.optional(), @@ -93,3 +101,4 @@ export type SongDetailsResponseDto = 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 febfd42..be9083f 100644 --- a/packages/core/src/modules/catalog/song/repository.interface.ts +++ b/packages/core/src/modules/catalog/song/repository.interface.ts @@ -6,6 +6,10 @@ import type { UpdateSongRequestDto, SongDetailsResponseDto, SongResponseDto, + SongLyricsCreateRequestDto, + SongLyricsUpdateRequestDto, + SongLyricsResponseDto, + SongLyricsListResponseDto, } from "./dto"; export abstract class ISongRepository implements IRepositoryWithGetDetails { @@ -18,4 +22,17 @@ export abstract class ISongRepository implements IRepositoryWithGetDetails; 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 10c15b1..a1b7aff 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"; @@ -37,7 +39,11 @@ export class SongRepository implements ISongRepository { artist: true, }, }, - lyrics: true, + lyrics: { + omit: { + deletedAt: true, + }, + }, }, omit: { deletedAt: true, @@ -66,13 +72,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, }; } @@ -84,15 +84,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, }, @@ -125,4 +117,60 @@ 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, + }, + }) + ); + } + + 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 0310aa0..ec58fa1 100644 --- a/packages/core/src/modules/catalog/song/service.ts +++ b/packages/core/src/modules/catalog/song/service.ts @@ -6,6 +6,10 @@ import type { CreateSongRequestDto, UpdateSongRequestDto, SongResponseDto, + SongLyricsCreateRequestDto, + SongLyricsUpdateRequestDto, + SongLyricsResponseDto, + SongLyricsListResponseDto, } from "./dto"; import type { ISongRepository } from "./repository.interface"; import { traceTask } from "@cvsa/observability"; @@ -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/unit/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index 27f356a..c95a0aa 100644 --- a/packages/core/tests/unit/SongService.test.ts +++ b/packages/core/tests/unit/SongService.test.ts @@ -44,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 = { From 2711dac3514b21ddb39c09aa5038dfd9291f9ab7 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 12 Apr 2026 18:18:22 +0800 Subject: [PATCH 4/6] chore: fix a small typo in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 77b981b..45cfbf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - `packages/core`: Shared business logic, DTOs, and Zod schemas - `packages/embedding`: A standalone embedding REST service - `packages/logger`: The application logger setup -- `pakcages/observability`: Observability configurations +- `packages/observability`: Observability configurations - `packages/env`: Package for managing environment variables ## 2. Core Mental Model & Architecture From b944c58831e90f6b9c9806443c599478e6acc8c3 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 12 Apr 2026 18:20:11 +0800 Subject: [PATCH 5/6] fix: use z.infer instead of z.Infer in song DTO --- packages/core/src/modules/catalog/song/dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/modules/catalog/song/dto.ts b/packages/core/src/modules/catalog/song/dto.ts index b89f2f8..6c1372d 100644 --- a/packages/core/src/modules/catalog/song/dto.ts +++ b/packages/core/src/modules/catalog/song/dto.ts @@ -93,7 +93,7 @@ export const SongLyricsResponseSchema = LyricsSchema.omit({ export const SongLyricsListResponseSchema = SongLyricsResponseSchema.array(); -export type SongResponseDto = Serialized>; +export type SongResponseDto = Serialized>; export type CreateSongRequestDto = Serialized>; export type UpdateSongRequestDto = Serialized>; export type SongDetailsResponseDto = Serialized>; From 01decb269ca0f9b2c49f506a68c7ef2b748db7ae Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 12 Apr 2026 18:50:24 +0800 Subject: [PATCH 6/6] test: add tests for lyrics module --- apps/backend/bunfig.toml | 2 + apps/backend/package.json | 4 +- .../backend/src/common/error/ConflictError.ts | 12 -- apps/backend/src/common/error/index.ts | 1 - .../src/modules/catalog/song/repository.ts | 14 +- .../tests/integration/SongRepository.test.ts | 194 +++++++++++++++++- packages/core/tests/unit/SongService.test.ts | 164 +++++++++++++++ 7 files changed, 374 insertions(+), 17 deletions(-) delete mode 100644 apps/backend/src/common/error/ConflictError.ts 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/packages/core/src/modules/catalog/song/repository.ts b/packages/core/src/modules/catalog/song/repository.ts index a1b7aff..7ac77bc 100644 --- a/packages/core/src/modules/catalog/song/repository.ts +++ b/packages/core/src/modules/catalog/song/repository.ts @@ -16,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 }, + }) ); } @@ -98,6 +101,9 @@ export class SongRepository implements ISongRepository { create: lyrics, }, }, + omit: { + deletedAt: true, + }, }) ); } @@ -109,6 +115,9 @@ export class SongRepository implements ISongRepository { await client.song.update({ where: { id }, data: input, + omit: { + deletedAt: true, + }, }) ); } @@ -127,6 +136,9 @@ export class SongRepository implements ISongRepository { songId: id, ...input, }, + omit: { + deletedAt: true, + }, }) ); } 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/SongService.test.ts b/packages/core/tests/unit/SongService.test.ts index c95a0aa..370a328 100644 --- a/packages/core/tests/unit/SongService.test.ts +++ b/packages/core/tests/unit/SongService.test.ts @@ -203,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"); + }); + }); });