From 03fc0171cadced6ecab0d3a60f0968f5e02736fb Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 29 Apr 2026 21:26:26 +0800 Subject: [PATCH 1/4] feat(singer): CRUD endpoints for singers --- .../src/handlers/catalog/singer/create.ts | 33 ++ .../src/handlers/catalog/singer/delete.ts | 34 ++ .../src/handlers/catalog/singer/get.ts | 29 ++ .../src/handlers/catalog/singer/index.ts | 11 + .../src/handlers/catalog/singer/update.ts | 39 +++ apps/backend/src/handlers/index.ts | 1 + apps/backend/src/index.ts | 3 +- apps/backend/tests/singer.test.ts | 321 ++++++++++++++++++ packages/core/src/modules/catalog/index.ts | 1 + .../src/modules/catalog/singer/container.ts | 6 + .../core/src/modules/catalog/singer/dto.ts | 31 ++ .../core/src/modules/catalog/singer/index.ts | 5 + .../catalog/singer/repository.interface.ts | 23 ++ .../src/modules/catalog/singer/repository.ts | 67 ++++ .../src/modules/catalog/singer/service.ts | 51 +++ 15 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/handlers/catalog/singer/create.ts create mode 100644 apps/backend/src/handlers/catalog/singer/delete.ts create mode 100644 apps/backend/src/handlers/catalog/singer/get.ts create mode 100644 apps/backend/src/handlers/catalog/singer/index.ts create mode 100644 apps/backend/src/handlers/catalog/singer/update.ts create mode 100644 apps/backend/tests/singer.test.ts create mode 100644 packages/core/src/modules/catalog/singer/container.ts create mode 100644 packages/core/src/modules/catalog/singer/dto.ts create mode 100644 packages/core/src/modules/catalog/singer/index.ts create mode 100644 packages/core/src/modules/catalog/singer/repository.interface.ts create mode 100644 packages/core/src/modules/catalog/singer/repository.ts create mode 100644 packages/core/src/modules/catalog/singer/service.ts diff --git a/apps/backend/src/handlers/catalog/singer/create.ts b/apps/backend/src/handlers/catalog/singer/create.ts new file mode 100644 index 0000000..5e405e4 --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/create.ts @@ -0,0 +1,33 @@ +import { Elysia } from "elysia"; +import { + CreateSingerRequestSchema, + ErrorResponseSchema, + SingerResponseSchema, + singerService, +} from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const singerCreateHandler = new Elysia({ name: "singerCreateHandler" }) + .use(authMiddleware) + .post( + "/singer", + async ({ body, status }) => { + const singer = await traceTask("singerService.create", async () => { + return await singerService.create(body); + }); + return status(201, singer); + }, + { + body: CreateSingerRequestSchema, + detail: { + summary: "Create Singer", + description: + "Create a new singer entry in the catalog. Requires authentication.", + }, + response: { + 201: SingerResponseSchema, + 401: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/catalog/singer/delete.ts b/apps/backend/src/handlers/catalog/singer/delete.ts new file mode 100644 index 0000000..4611170 --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/delete.ts @@ -0,0 +1,34 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { ErrorResponseSchema, singerService } from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const singerDeleteHandler = new Elysia({ name: "singerDeleteHandler" }) + .use(authMiddleware) + .delete( + "/singer/:id", + async ({ params, set }) => { + await traceTask("singerService.delete", async () => { + return await singerService.delete(params.id); + }); + set.status = 204; + return null; + }, + { + params: z.object({ + id: z.coerce.number().int().positive(), + }), + detail: { + summary: "Delete Singer", + description: + "Soft delete a singer from the catalog. Requires authentication. The singer record is marked as deleted but retained in the database for data integrity.", + }, + response: { + 204: z.null(), + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/catalog/singer/get.ts b/apps/backend/src/handlers/catalog/singer/get.ts new file mode 100644 index 0000000..d9810d2 --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/get.ts @@ -0,0 +1,29 @@ +import { Elysia } from "elysia"; +import { ErrorResponseSchema, SingerDetailsResponseSchema, singerService } from "@cvsa/core"; +import z from "zod"; +import { traceTask } from "@/common/trace"; + +export const singerGetHandler = new Elysia().get( + "/singer/:id", + async ({ params, status }) => { + const singer = await traceTask("singerService.getDetails", async () => { + return await singerService.getDetails(params.id); + }); + return status(200, singer); + }, + { + detail: { + summary: "Get Singer", + description: + "Retrieve detailed information about a specific singer by its ID. Includes name, description, language, and metadata.", + }, + response: { + 200: SingerDetailsResponseSchema, + 400: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + params: z.object({ + id: z.coerce.number().int().positive(), + }), + } +); diff --git a/apps/backend/src/handlers/catalog/singer/index.ts b/apps/backend/src/handlers/catalog/singer/index.ts new file mode 100644 index 0000000..9c7824e --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/index.ts @@ -0,0 +1,11 @@ +import { Elysia } from "elysia"; +import { singerCreateHandler } from "./create"; +import { singerUpdateHandler } from "./update"; +import { singerDeleteHandler } from "./delete"; +import { singerGetHandler } from "./get"; + +export const singerHandler = new Elysia({ name: "singerHandler" }) + .use(singerGetHandler) + .use(singerCreateHandler) + .use(singerUpdateHandler) + .use(singerDeleteHandler); diff --git a/apps/backend/src/handlers/catalog/singer/update.ts b/apps/backend/src/handlers/catalog/singer/update.ts new file mode 100644 index 0000000..519e93b --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/update.ts @@ -0,0 +1,39 @@ +import { Elysia } from "elysia"; +import { z } from "zod"; +import { + UpdateSingerRequestSchema, + ErrorResponseSchema, + singerService, + SingerResponseSchema, +} from "@cvsa/core"; +import { authMiddleware } from "@/middlewares"; +import { traceTask } from "@/common/trace"; + +export const singerUpdateHandler = new Elysia({ name: "singerUpdateHandler" }) + .use(authMiddleware) + .patch( + "/singer/:id", + async ({ params, body, status }) => { + const singer = await traceTask("singerService.update", async () => { + return await singerService.update(params.id, body); + }); + return status(200, singer); + }, + { + body: UpdateSingerRequestSchema, + params: z.object({ + id: z.coerce.number().int().positive(), + }), + detail: { + summary: "Update Singer", + description: + "Update metadata of an existing singer identified by its ID. Requires authentication.", + }, + response: { + 200: SingerResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + } + ); diff --git a/apps/backend/src/handlers/index.ts b/apps/backend/src/handlers/index.ts index 768f891..4e3b6cd 100644 --- a/apps/backend/src/handlers/index.ts +++ b/apps/backend/src/handlers/index.ts @@ -2,4 +2,5 @@ export * from "./auth"; export * from "./catalog/song"; export * from "./catalog/engine"; export * from "./catalog/artist"; +export * from "./catalog/singer"; export * from "./dev"; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index d8407cd..c164a0b 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,7 +2,7 @@ import { Elysia } from "elysia"; import { onAfterHandler } from "./onAfterHandle"; import { getBindingInfo, logStartup } from "./startMessage"; import pkg from "../package.json"; -import { authHandler, songHandler, engineHandler, artistHandler } from "@handlers/index"; +import { authHandler, songHandler, engineHandler, artistHandler, singerHandler } from "@handlers/index"; import { errorHandler } from "./errorHandler"; import { openapi } from "@elysiajs/openapi"; import { requestLoggerMiddleware } from "@/middlewares"; @@ -58,6 +58,7 @@ export const app = new Elysia({ .use(songHandler) .use(engineHandler) .use(artistHandler) + .use(singerHandler) .use(devHandler) .listen(16412); diff --git a/apps/backend/tests/singer.test.ts b/apps/backend/tests/singer.test.ts new file mode 100644 index 0000000..ace461f --- /dev/null +++ b/apps/backend/tests/singer.test.ts @@ -0,0 +1,321 @@ +import { 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("Singer E2E Tests", () => { + async function getAuthToken() { + const signup = await api.v2.user.post({ + username: `${Math.random()}`, + password: "password123", + }); + return signup.data?.data.token; + } + + describe("GET /v2/singer/:id - Get Singer", () => { + test("should retrieve a singer", async () => { + const singer = await prisma.singer.create({ + data: { + name: "Test Singer", + description: "Test Description", + language: "zh", + }, + }); + + const { data, status } = await api.v2.singer({ id: singer.id }).get(); + + expect(status).toBe(200); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "Test Singer", + description: "Test Description", + language: "zh", + }); + }); + + test("should return 404 for nonexistent singer", async () => { + const { error, status } = await api.v2.singer({ id: 99999 }).get(); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + + test("should not retrieve soft-deleted singer", async () => { + const singer = await prisma.singer.create({ + data: { + name: "Deleted Singer", + deletedAt: new Date(), + }, + }); + + const { error, status } = await api.v2.singer({ id: singer.id }).get(); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + }); + + describe("POST /v2/singer - Create Singer", () => { + test("should create a singer with authentication", async () => { + const token = await getAuthToken(); + + const payload = { + name: "New Singer", + description: "A virtual singer", + language: "zh", + }; + + const { data, status } = await api.v2.singer.post(payload, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(status).toBe(201); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "New Singer", + description: "A virtual singer", + language: "zh", + }); + }); + + test("should return 401 without authentication", async () => { + const payload = { + name: "Test Singer", + }; + + const { error, status } = await api.v2.singer.post(payload); + + expect(status).toBe(401); + expect(error?.value).toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("should create a singer with localized names and descriptions", async () => { + const token = await getAuthToken(); + + const payload = { + name: "Singer Name", + localizedNames: { + zh: "歌手名称", + ja: "シンガー名", + }, + description: "Description in English", + localizedDescriptions: { + zh: "中文描述", + ja: "日本語の説明", + }, + language: "zh", + }; + + const { data, status } = await api.v2.singer.post(payload, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(status).toBe(201); + expect(data).toMatchObject({ + id: expect.any(Number), + name: "Singer Name", + localizedNames: { + zh: "歌手名称", + ja: "シンガー名", + }, + localizedDescriptions: { + zh: "中文描述", + ja: "日本語の説明", + }, + }); + }); + + test("should create a singer with minimal fields", async () => { + const token = await getAuthToken(); + + const { data, status } = await api.v2.singer.post( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(201); + expect(data).toMatchObject({ + id: expect.any(Number), + }); + }); + }); + + describe("PATCH /v2/singer/:id - Update Singer", () => { + test("should update a singer with authentication", async () => { + const token = await getAuthToken(); + + const singer = await prisma.singer.create({ + data: { + name: "Original Name", + description: "Original Description", + language: "zh", + }, + }); + + const { data, status } = await api.v2.singer({ id: singer.id }).patch( + { name: "Updated Name", description: "Updated Description", language: "en" }, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(200); + expect(data?.name).toBe("Updated Name"); + expect(data?.description).toBe("Updated Description"); + expect(data?.language).toBe("en"); + }); + + test("should return 401 without authentication", async () => { + const singer = await prisma.singer.create({ + data: { + name: "Test Singer", + }, + }); + + const { status } = await api.v2.singer({ id: singer.id }).patch({ + name: "Updated Name", + }); + + expect(status).toBe(401); + }); + + test("should return 404 for non-existent singer", async () => { + const token = await getAuthToken(); + + const { error, status } = await api.v2.singer({ id: 999999 }).patch( + { name: "Updated Name" }, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + + test("should update localized names", async () => { + const token = await getAuthToken(); + + const singer = await prisma.singer.create({ + data: { + name: "Singer", + localizedNames: { zh: "旧名" }, + }, + }); + + const { data, status } = await api.v2.singer({ id: singer.id }).patch( + { localizedNames: { zh: "新名", ja: "新しい名前" } }, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(200); + expect(data?.localizedNames).toEqual({ zh: "新名", ja: "新しい名前" }); + }); + }); + + describe("DELETE /v2/singer/:id - Delete Singer", () => { + test("should soft delete a singer with authentication", async () => { + const token = await getAuthToken(); + + const singer = await prisma.singer.create({ + data: { + name: "Singer to Delete", + }, + }); + + const { status } = await api.v2.singer({ id: singer.id }).delete( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(204); + + const deletedSinger = await prisma.singer.findUnique({ + where: { id: singer.id }, + }); + expect(deletedSinger?.deletedAt).not.toBeNull(); + }); + + test("should return 401 without authentication", async () => { + const singer = await prisma.singer.create({ + data: { + name: "Test Singer", + }, + }); + + const { status } = await api.v2.singer({ id: singer.id }).delete({}, {}); + + expect(status).toBe(401); + }); + + test("should return 404 for non-existent singer", async () => { + const token = await getAuthToken(); + + const { status } = await api.v2.singer({ id: 999999 }).delete( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(404); + }); + + test("should return 404 when trying to delete already deleted singer", async () => { + const token = await getAuthToken(); + + const singer = await prisma.singer.create({ + data: { + name: "Already Deleted", + deletedAt: new Date(), + }, + }); + + const { error, status } = await api.v2.singer({ id: singer.id }).delete( + {}, + { + headers: { + authorization: `Bearer ${token}`, + }, + } + ); + + expect(status).toBe(404); + expect(error?.value).toMatchObject({ + code: "NOT_FOUND", + }); + }); + }); +}); diff --git a/packages/core/src/modules/catalog/index.ts b/packages/core/src/modules/catalog/index.ts index 6b142ba..751a0f3 100644 --- a/packages/core/src/modules/catalog/index.ts +++ b/packages/core/src/modules/catalog/index.ts @@ -1,3 +1,4 @@ export * from "./song"; export * from "./engine"; export * from "./artist"; +export * from "./singer"; diff --git a/packages/core/src/modules/catalog/singer/container.ts b/packages/core/src/modules/catalog/singer/container.ts new file mode 100644 index 0000000..5957951 --- /dev/null +++ b/packages/core/src/modules/catalog/singer/container.ts @@ -0,0 +1,6 @@ +import { prisma } from "@cvsa/db"; +import { SingerRepository } from "./repository"; +import { SingerService } from "./service"; + +export const singerRepository = new SingerRepository(prisma); +export const singerService = new SingerService(singerRepository); diff --git a/packages/core/src/modules/catalog/singer/dto.ts b/packages/core/src/modules/catalog/singer/dto.ts new file mode 100644 index 0000000..2ce21bf --- /dev/null +++ b/packages/core/src/modules/catalog/singer/dto.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { SingerSchema, type Serialized } from "@cvsa/db"; + +export type SingerId = number; + +export const CreateSingerRequestSchema = z.object({ + name: z.string().optional(), + avatarUrl: z.string().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(), +}); + +export const UpdateSingerRequestSchema = z.object({ + name: z.string().optional(), + avatarUrl: z.string().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(), +}); + +export const SingerResponseSchema = SingerSchema.omit({ deletedAt: true }); + +export const SingerDetailsResponseSchema = SingerResponseSchema; + +export type SingerResponseDto = Serialized>; +export type SingerDetailsResponseDto = Serialized>; +export type CreateSingerRequestDto = Serialized>; +export type UpdateSingerRequestDto = Serialized>; diff --git a/packages/core/src/modules/catalog/singer/index.ts b/packages/core/src/modules/catalog/singer/index.ts new file mode 100644 index 0000000..132cf0c --- /dev/null +++ b/packages/core/src/modules/catalog/singer/index.ts @@ -0,0 +1,5 @@ +export * from "./dto"; +export * from "./repository"; +export * from "./service"; +export * from "./repository.interface"; +export * from "./container"; diff --git a/packages/core/src/modules/catalog/singer/repository.interface.ts b/packages/core/src/modules/catalog/singer/repository.interface.ts new file mode 100644 index 0000000..a1c86ac --- /dev/null +++ b/packages/core/src/modules/catalog/singer/repository.interface.ts @@ -0,0 +1,23 @@ +import type { TxClient } from "@cvsa/db"; +import type { IRepositoryWithGetDetails } from "@cvsa/core/internal"; +import type { + CreateSingerRequestDto, + SingerId, + UpdateSingerRequestDto, + SingerResponseDto, + SingerDetailsResponseDto, +} from "./dto"; + +export abstract class ISingerRepository + implements IRepositoryWithGetDetails +{ + abstract getById(id: SingerId, tx?: TxClient): Promise; + abstract getDetailsById(id: SingerId, tx?: TxClient): Promise; + abstract create(input: CreateSingerRequestDto, tx?: TxClient): Promise; + abstract update( + id: SingerId, + input: UpdateSingerRequestDto, + tx?: TxClient + ): Promise; + abstract softDelete(id: SingerId, tx?: TxClient): Promise; +} diff --git a/packages/core/src/modules/catalog/singer/repository.ts b/packages/core/src/modules/catalog/singer/repository.ts new file mode 100644 index 0000000..7f8ce59 --- /dev/null +++ b/packages/core/src/modules/catalog/singer/repository.ts @@ -0,0 +1,67 @@ +import type { PrismaClient } from "@cvsa/db"; +import type { TxClient } from "@cvsa/db"; +import { BaseRepository } from "../../../utils/BaseRepository"; +import type { + CreateSingerRequestDto, + SingerId, + UpdateSingerRequestDto, + SingerDetailsResponseDto, +} from "./dto"; +import type { ISingerRepository } from "./repository.interface"; + +export class SingerRepository extends BaseRepository implements ISingerRepository { + constructor(private readonly prisma: PrismaClient) { + super(); + } + + async getById(id: SingerId, tx?: TxClient) { + const client = tx ?? this.prisma; + return this.query("db.singer.getById", () => + client.singer.findFirst({ + where: { id, deletedAt: null }, + omit: { deletedAt: true }, + }) + ); + } + + async getDetailsById(id: SingerId, tx?: TxClient): Promise { + const client = tx ?? this.prisma; + return this.query("db.singer.getDetailsById", () => + client.singer.findFirst({ + where: { id, deletedAt: null }, + omit: { deletedAt: true }, + }) + ); + } + + async create(input: CreateSingerRequestDto, tx?: TxClient) { + const client = tx ?? this.prisma; + return this.query("db.singer.create", () => + client.singer.create({ + data: input, + omit: { deletedAt: true }, + }) + ); + } + + async update(id: SingerId, input: UpdateSingerRequestDto, tx?: TxClient) { + const client = tx ?? this.prisma; + return this.query("db.singer.update", () => + client.singer.update({ + where: { id }, + data: input, + omit: { deletedAt: true }, + }) + ); + } + + async softDelete(id: SingerId, tx?: TxClient) { + const client = tx ?? this.prisma; + await this.query("db.singer.softDelete", () => + client.singer.update({ + where: { id }, + data: { deletedAt: new Date() }, + }) + ); + } +} diff --git a/packages/core/src/modules/catalog/singer/service.ts b/packages/core/src/modules/catalog/singer/service.ts new file mode 100644 index 0000000..5104971 --- /dev/null +++ b/packages/core/src/modules/catalog/singer/service.ts @@ -0,0 +1,51 @@ +import { AppError } from "../../../error/AppError"; +import type { IServiceWithGetDetails } from "../../../types/service"; +import type { + SingerDetailsResponseDto, + SingerId, + CreateSingerRequestDto, + UpdateSingerRequestDto, + SingerResponseDto, +} from "./dto"; +import type { ISingerRepository } from "./repository.interface"; +import { traceTask } from "@cvsa/observability"; + +export class SingerService implements IServiceWithGetDetails { + constructor(private readonly repository: ISingerRepository) {} + + async getDetails(id: SingerId) { + return traceTask("db findOne singer", async () => { + const result = await this.repository.getDetailsById(id); + if (result === null) { + throw new AppError("error.singer.notfound", "NOT_FOUND", 404); + } + return result; + }); + } + + async create(input: CreateSingerRequestDto): Promise { + return traceTask("db create singer", async () => { + return await this.repository.create(input); + }); + } + + async update(id: SingerId, input: UpdateSingerRequestDto): Promise { + const existing = await this.repository.getById(id); + if (existing === null) { + throw new AppError("error.singer.notfound", "NOT_FOUND", 404); + } + return traceTask("db update singer", async () => { + return await this.repository.update(id, input); + }); + } + + async delete(id: SingerId): Promise { + const existing = await this.repository.getById(id); + if (existing === null) { + throw new AppError("error.singer.notfound", "NOT_FOUND", 404); + } + await traceTask("db delete singer", async () => { + return await this.repository.softDelete(id); + }); + } +} From 81cd61c863fa82201c6fb165ac8ba9c9b131f60f Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 29 Apr 2026 21:46:18 +0800 Subject: [PATCH 2/4] feat(singer): search support for singers --- .../src/handlers/catalog/singer/index.ts | 4 +- .../src/handlers/catalog/singer/search.ts | 25 +++++ .../src/modules/catalog/singer/container.ts | 13 ++- .../src/modules/catalog/singer/service.ts | 68 +++++++++--- packages/core/src/outbox/processor.ts | 3 +- packages/core/src/search/catalog/index.ts | 1 + packages/core/src/search/catalog/singer.ts | 103 ++++++++++++++++++ packages/core/src/search/config.ts | 10 ++ 8 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/handlers/catalog/singer/search.ts create mode 100644 packages/core/src/search/catalog/singer.ts diff --git a/apps/backend/src/handlers/catalog/singer/index.ts b/apps/backend/src/handlers/catalog/singer/index.ts index 9c7824e..afa6e42 100644 --- a/apps/backend/src/handlers/catalog/singer/index.ts +++ b/apps/backend/src/handlers/catalog/singer/index.ts @@ -3,9 +3,11 @@ import { singerCreateHandler } from "./create"; import { singerUpdateHandler } from "./update"; import { singerDeleteHandler } from "./delete"; import { singerGetHandler } from "./get"; +import { singerSearchHandler } from "./search"; export const singerHandler = new Elysia({ name: "singerHandler" }) .use(singerGetHandler) .use(singerCreateHandler) .use(singerUpdateHandler) - .use(singerDeleteHandler); + .use(singerDeleteHandler) + .use(singerSearchHandler); diff --git a/apps/backend/src/handlers/catalog/singer/search.ts b/apps/backend/src/handlers/catalog/singer/search.ts new file mode 100644 index 0000000..2485da9 --- /dev/null +++ b/apps/backend/src/handlers/catalog/singer/search.ts @@ -0,0 +1,25 @@ +import { Elysia } from "elysia"; +import { singerSearchService } from "@cvsa/core"; +import z from "zod"; +import { traceTask } from "@/common/trace"; + +// TODO: add corresponding DTO and response schema +export const singerSearchHandler = new Elysia().get( + "/singers", + async ({ query, status }) => { + const song = await traceTask("singerSearchService.search", async () => { + return await singerSearchService.search(query.q || ""); + }); + return status(200, song); + }, + { + detail: { + summary: "Search for Singers", + description: + "Full-text search across singer names, and descriptions. Returns a list of matching signers ordered by relevance.", + }, + query: z.object({ + q: z.string().optional(), + }), + } +); diff --git a/packages/core/src/modules/catalog/singer/container.ts b/packages/core/src/modules/catalog/singer/container.ts index 5957951..daccb49 100644 --- a/packages/core/src/modules/catalog/singer/container.ts +++ b/packages/core/src/modules/catalog/singer/container.ts @@ -1,6 +1,17 @@ import { prisma } from "@cvsa/db"; import { SingerRepository } from "./repository"; import { SingerService } from "./service"; +import { SingerSearchService, searchManager } from "../../../search"; +import { treaty } from "@elysiajs/eden"; +import type { EmbeddingApp } from "@cvsa/embedding"; +import { outboxService } from "../../outbox/container"; + +const embeddingManager = treaty("localhost:14900"); export const singerRepository = new SingerRepository(prisma); -export const singerService = new SingerService(singerRepository); +export const singerSearchService = new SingerSearchService( + singerRepository, + searchManager, + embeddingManager +); +export const singerService = new SingerService(singerRepository, outboxService); diff --git a/packages/core/src/modules/catalog/singer/service.ts b/packages/core/src/modules/catalog/singer/service.ts index 5104971..8553395 100644 --- a/packages/core/src/modules/catalog/singer/service.ts +++ b/packages/core/src/modules/catalog/singer/service.ts @@ -1,5 +1,7 @@ +import type { OutboxService } from "../../outbox/service"; import { AppError } from "../../../error/AppError"; import type { IServiceWithGetDetails } from "../../../types/service"; +import { prisma } from "@cvsa/db"; import type { SingerDetailsResponseDto, SingerId, @@ -8,25 +10,37 @@ import type { SingerResponseDto, } from "./dto"; import type { ISingerRepository } from "./repository.interface"; -import { traceTask } from "@cvsa/observability"; export class SingerService implements IServiceWithGetDetails { - constructor(private readonly repository: ISingerRepository) {} + constructor( + private readonly repository: ISingerRepository, + private readonly outbox: OutboxService + ) {} async getDetails(id: SingerId) { - return traceTask("db findOne singer", async () => { - const result = await this.repository.getDetailsById(id); - if (result === null) { - throw new AppError("error.singer.notfound", "NOT_FOUND", 404); - } - return result; - }); + const result = await this.repository.getDetailsById(id); + if (result === null) { + throw new AppError("error.singer.notfound", "NOT_FOUND", 404); + } + return result; } async create(input: CreateSingerRequestDto): Promise { - return traceTask("db create singer", async () => { - return await this.repository.create(input); + const { singer, entry } = await prisma.$transaction(async (tx) => { + const singer = await this.repository.create(input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "singer", + aggregateId: singer.id, + eventType: "singer.created", + }, + tx + ); + return { singer, entry }; }); + + await this.outbox.enqueue(entry); + return singer; } async update(id: SingerId, input: UpdateSingerRequestDto): Promise { @@ -34,9 +48,22 @@ export class SingerService implements IServiceWithGetDetails { - return await this.repository.update(id, input); + + const { singer, entry } = await prisma.$transaction(async (tx) => { + const singer = await this.repository.update(id, input, tx); + const entry = await this.outbox.createEntry( + { + aggregateType: "singer", + aggregateId: id, + eventType: "singer.updated", + }, + tx + ); + return { singer, entry }; }); + + await this.outbox.enqueue(entry); + return singer; } async delete(id: SingerId): Promise { @@ -44,8 +71,19 @@ export class SingerService implements IServiceWithGetDetails { - return await this.repository.softDelete(id); + + const entry = await prisma.$transaction(async (tx) => { + await this.repository.softDelete(id, tx); + return await this.outbox.createEntry( + { + aggregateType: "singer", + aggregateId: id, + eventType: "singer.deleted", + }, + tx + ); }); + + await this.outbox.enqueue(entry); } } diff --git a/packages/core/src/outbox/processor.ts b/packages/core/src/outbox/processor.ts index de224e7..70ad4de 100644 --- a/packages/core/src/outbox/processor.ts +++ b/packages/core/src/outbox/processor.ts @@ -1,6 +1,6 @@ import type { Job } from "bullmq"; import type { OutboxEntryDto, OutboxService } from "@cvsa/core/internal"; -import { songSearchService, artistSearchService } from "@cvsa/core/internal"; +import { songSearchService, artistSearchService, singerSearchService } from "@cvsa/core/internal"; import { outboxService } from "../modules/outbox/container"; import { appLogger } from "@cvsa/logger"; @@ -44,5 +44,6 @@ export const processOutboxEntry = createOutboxProcessor({ searchServices: { song: songSearchService, artist: artistSearchService, + singer: singerSearchService, }, }); diff --git a/packages/core/src/search/catalog/index.ts b/packages/core/src/search/catalog/index.ts index 9d9a57c..0f68be0 100644 --- a/packages/core/src/search/catalog/index.ts +++ b/packages/core/src/search/catalog/index.ts @@ -1,2 +1,3 @@ export * from "./song"; export * from "./artist"; +export * from "./singer"; diff --git a/packages/core/src/search/catalog/singer.ts b/packages/core/src/search/catalog/singer.ts new file mode 100644 index 0000000..1084c62 --- /dev/null +++ b/packages/core/src/search/catalog/singer.ts @@ -0,0 +1,103 @@ +import { ISearchService } from "../interface"; +import { unique, keys } from "remeda"; +import type { SingerDetailsResponseDto } from "../../modules"; +import { appLogger } from "@cvsa/logger"; + +export interface SingerSearchIndex { + id: number; + name?: string; + description?: string; + language?: string; + _vectors?: { + "potion-multilingual-128M": number[] | null; + }; +} + +export class SingerSearchService extends ISearchService { + private async getDocument( + singer: SingerDetailsResponseDto, + language: string + ): Promise { + const getDesc = () => { + if (language === singer.language) return singer.description; + return singer.localizedDescriptions?.[language]; + }; + const getName = () => { + if (language === singer.language) return singer.name; + return singer.localizedNames?.[language]; + }; + + const vectors = await this.embeddingManager.embeddings.post({ + texts: [ + `Name: ${getName() ?? ""} +Description: ${getDesc() ?? ""} +`, + ], + }); + return { + id: singer.id, + name: getName() ?? undefined, + description: getDesc() ?? undefined, + _vectors: vectors.data?.embeddings[0] + ? { + "potion-multilingual-128M": vectors.data?.embeddings[0], + } + : { + "potion-multilingual-128M": null, + }, + }; + } + + public async sync(id: number) { + if (!this.searchManager) { + appLogger.warn("Search service not available"); + return; + } + const singer = await this.repository.getDetailsById(id); + + if (!singer) { + const indexesToBeDeleted = + await this.searchManager.getLocalizedIndexesOfEntity("singer"); + for (const index of indexesToBeDeleted) { + const adminIndex = await this.searchManager.getAdminIndex(index); + const task = await adminIndex.deleteDocument(id); + await this.searchManager.waitForTask(task.taskUid); + } + return; + } + + const languages = unique([ + ...keys(singer.localizedNames ?? []), + ...keys(singer.localizedDescriptions ?? []), + singer.language, + ]); + for (const language of languages) { + const indexUid = `singer_${language}`; + const index = await this.searchManager.getAdminIndex(indexUid); + const document = await this.getDocument(singer, language); + const task = await index.addDocuments([document], { + primaryKey: "id", + }); + await this.searchManager.waitForTask(task.taskUid); + } + } + + public async search(query: string, language: string = "zh") { + if (!this.searchManager) { + throw new Error("Search or embedding service not available"); + } + + const index = await this.searchManager.getSearchIndex(`singer_${language}`); + const embeddingResponse = await this.embeddingManager.embeddings.post({ + texts: [query], + }); + return index.search(query, { + vector: embeddingResponse?.data?.embeddings[0], + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + } +} diff --git a/packages/core/src/search/config.ts b/packages/core/src/search/config.ts index 410db0a..38937be 100644 --- a/packages/core/src/search/config.ts +++ b/packages/core/src/search/config.ts @@ -57,4 +57,14 @@ export const INDEX_SETTINGS: Record = { }, }, }, + singer: { + searchableAttributes: ["name", "description", "language"], + rankingRules: ["attribute", "words", "proximity", "exactness", "typo", "sort"], + embedders: { + "potion-multilingual-128M": { + source: "userProvided", + dimensions: 256, + }, + }, + }, }; From 9e62338a36afb8841c69a9f08938e84a4868023b Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 29 Apr 2026 22:06:22 +0800 Subject: [PATCH 3/4] test: unit&integration tests for singers --- .../integration/SingerRepository.test.ts | 153 +++++++++++ .../tests/unit/SingerSearchService.test.ts | 243 ++++++++++++++++++ .../core/tests/unit/SingerService.test.ts | 182 +++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 packages/core/tests/integration/SingerRepository.test.ts create mode 100644 packages/core/tests/unit/SingerSearchService.test.ts create mode 100644 packages/core/tests/unit/SingerService.test.ts diff --git a/packages/core/tests/integration/SingerRepository.test.ts b/packages/core/tests/integration/SingerRepository.test.ts new file mode 100644 index 0000000..3b9a841 --- /dev/null +++ b/packages/core/tests/integration/SingerRepository.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { + singerRepository, + type CreateSingerRequestDto, + type UpdateSingerRequestDto, +} from "@cvsa/core"; + +const repository = singerRepository; + +describe("SingerRepository Integration Tests", () => { + describe("create", () => { + test("should create a singer with all fields", async () => { + const input: CreateSingerRequestDto = { + name: "洛天依", + avatarUrl: "https://example.com/luotianyi.jpg", + localizedNames: { ja: "洛天依", en: "Luo Tianyi" }, + language: "zh", + description: "A famous Chinese virtual singer developed by Shanghai Henian", + localizedDescriptions: { + en: "A famous Chinese virtual singer", + ja: "有名な中国のバーチャルシンガー", + }, + }; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBe("洛天依"); + expect(result.avatarUrl).toBe("https://example.com/luotianyi.jpg"); + expect(result.localizedNames).toEqual({ ja: "洛天依", en: "Luo Tianyi" }); + expect(result.language).toBe("zh"); + expect(result.description).toBe( + "A famous Chinese virtual singer developed by Shanghai Henian" + ); + expect(result.localizedDescriptions).toEqual({ + en: "A famous Chinese virtual singer", + ja: "有名な中国のバーチャルシンガー", + }); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should create a singer with minimal fields", async () => { + const input: CreateSingerRequestDto = {}; + + const result = await repository.create(input); + + expect(result).toBeDefined(); + expect(result.id).toBeGreaterThan(0); + expect(result.name).toBeNull(); + expect(result.language).toBe("zh"); + expect(result.description).toBeNull(); + expect(result.localizedNames).toBeNull(); + expect(result.localizedDescriptions).toBeNull(); + }); + }); + + describe("getById", () => { + test("should return singer when exists", async () => { + const created = await repository.create({ name: "乐正绫" }); + const result = await repository.getById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("乐正绫"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when singer does not exist", async () => { + const result = await repository.getById(999999); + expect(result).toBeNull(); + }); + }); + + describe("getDetailsById", () => { + test("should return singer details when exists", async () => { + const created = await repository.create({ + name: "言和", + description: "Voicebank of YANHE", + }); + const result = await repository.getDetailsById(created.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(created.id); + expect(result?.name).toBe("言和"); + expect(result?.description).toBe("Voicebank of YANHE"); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result?.deletedAt).not.toBeDefined(); + }); + + test("should return null when singer does not exist", async () => { + const result = await repository.getDetailsById(999999); + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + test("should update all fields", async () => { + const created = await repository.create({ + name: "Old Name", + language: "zh", + description: "Old description", + }); + + const input: UpdateSingerRequestDto = { + name: "New Name", + avatarUrl: "https://example.com/new.jpg", + language: "ja", + description: "Updated description", + localizedNames: { en: "New Name EN" }, + localizedDescriptions: { en: "Updated description EN" }, + }; + + const result = await repository.update(created.id, input); + + expect(result.name).toBe("New Name"); + expect(result.avatarUrl).toBe("https://example.com/new.jpg"); + expect(result.language).toBe("ja"); + expect(result.description).toBe("Updated description"); + expect(result.localizedNames).toEqual({ en: "New Name EN" }); + expect(result.localizedDescriptions).toEqual({ en: "Updated description EN" }); + //@ts-expect-error accessing nonexistent field for testing purpose + expect(result.deletedAt).not.toBeDefined(); + }); + + test("should update only specified fields", async () => { + const created = await repository.create({ + name: "Original Name", + language: "zh", + description: "Original description", + }); + + const result = await repository.update(created.id, { name: "Updated Name" }); + + expect(result.name).toBe("Updated Name"); + expect(result.language).toBe("zh"); + expect(result.description).toBe("Original description"); + }); + }); + + describe("softDelete", () => { + test("should soft delete a singer", async () => { + const created = await repository.create({ name: "Singer to Delete" }); + + await repository.softDelete(created.id); + + const result = await repository.getById(created.id); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/tests/unit/SingerSearchService.test.ts b/packages/core/tests/unit/SingerSearchService.test.ts new file mode 100644 index 0000000..54a81a5 --- /dev/null +++ b/packages/core/tests/unit/SingerSearchService.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, mock, test, beforeEach, afterAll } from "bun:test"; +import type { SingerDetailsResponseDto } from "../../src/modules"; +import type { IRepositoryWithGetDetails } from "../../src/types/repository"; + +const mockGetLocalizedIndexesOfEntity = mock(); +const mockGetAdminIndex = mock(); +const mockGetSearchIndex = mock(); + +const mockAdminIndex = { + deleteDocument: mock(), + addDocuments: mock(), + search: mock(), +}; + +const mockSearchIndex = { + search: mock(), +}; + +const mockSearchManager = { + getLocalizedIndexesOfEntity: mockGetLocalizedIndexesOfEntity, + getAdminIndex: mockGetAdminIndex, + getSearchIndex: mockGetSearchIndex, + waitForTask: mock().mockResolvedValue(undefined), +}; + +const mockEmbeddingsPost = mock(); +const mockEmbeddingManager = { + embeddings: { + post: mockEmbeddingsPost, + }, +}; + +const { SingerSearchService } = await import("../../src/search/catalog/singer"); + +const mockSingerDetails: SingerDetailsResponseDto = { + id: 1, + name: "Test Singer", + language: "zh", + avatarUrl: null, + description: "A test singer", + localizedNames: { en: "Test Singer EN", ja: "テストシンガー" }, + localizedDescriptions: { en: "A test singer in English" }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +describe("SingerSearchService", () => { + let service: InstanceType; + let mockRepository: IRepositoryWithGetDetails; + + beforeEach(() => { + mockGetLocalizedIndexesOfEntity.mockClear(); + mockGetAdminIndex.mockClear(); + mockGetSearchIndex.mockClear(); + mockAdminIndex.deleteDocument.mockClear(); + mockAdminIndex.addDocuments.mockClear(); + mockAdminIndex.search.mockClear(); + mockSearchIndex.search.mockClear(); + mockEmbeddingsPost.mockClear(); + + mockGetAdminIndex.mockResolvedValue(mockAdminIndex); + mockGetSearchIndex.mockResolvedValue(mockSearchIndex); + mockAdminIndex.deleteDocument.mockResolvedValue({ taskUid: 1 }); + mockAdminIndex.addDocuments.mockResolvedValue({ taskUid: 1 }); + mockEmbeddingsPost.mockResolvedValue({ + data: { embeddings: [[0.1, 0.2, 0.3]] }, + }); + + mockRepository = { + getDetailsById: mock(), + }; + + service = new SingerSearchService( + mockRepository, + mockSearchManager as never, + mockEmbeddingManager as never + ); + }); + + afterAll(() => { + mock.restore(); + mock.clearAllMocks(); + }); + + describe("sync", () => { + test("syncs singer to all language indexes", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockSingerDetails + ); + + await service.sync(1); + + expect(mockRepository.getDetailsById).toHaveBeenCalledWith(1); + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const callArgs = mockAdminIndex.addDocuments.mock.calls[0] as unknown[]; + expect(callArgs[1]).toEqual({ primaryKey: "id" }); + }); + + test("deletes document from all indexes when singer not found", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue(null); + mockGetLocalizedIndexesOfEntity.mockResolvedValue(["singer_zh", "singer_en"]); + + await service.sync(999); + + expect(mockGetLocalizedIndexesOfEntity).toHaveBeenCalledWith("singer"); + expect(mockGetAdminIndex).toHaveBeenCalledTimes(2); + expect(mockAdminIndex.deleteDocument).toHaveBeenCalledTimes(2); + expect(mockAdminIndex.deleteDocument).toHaveBeenCalledWith(999); + }); + + test("handles missing search manager gracefully", async () => { + const serviceWithoutManager = new SingerSearchService( + mockRepository, + undefined as never, + mockEmbeddingManager as never + ); + + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockSingerDetails + ); + + await serviceWithoutManager.sync(1); + + expect(mockRepository.getDetailsById).not.toHaveBeenCalled(); + }); + + test("builds document with localized content", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockSingerDetails + ); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const allCalls = mockAdminIndex.addDocuments.mock.calls as unknown as [ + { id: number; name: string }[], + unknown, + ][]; + const enCall = allCalls.find((call) => call[0][0].name === "Test Singer EN"); + expect(enCall).toBeDefined(); + }); + + test("handles singer with no localized content", async () => { + const singerWithoutLocalization = { + ...mockSingerDetails, + localizedNames: {}, + localizedDescriptions: {}, + }; + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + singerWithoutLocalization + ); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + }); + + test("handles embedding generation failure", async () => { + (mockRepository.getDetailsById as ReturnType).mockResolvedValue( + mockSingerDetails + ); + mockEmbeddingsPost.mockResolvedValue({ + data: null, + }); + + await service.sync(1); + + expect(mockAdminIndex.addDocuments).toHaveBeenCalled(); + const doc = ( + (mockAdminIndex.addDocuments.mock.calls[0] as unknown[])[0] as { + _vectors: { "potion-multilingual-128M": unknown }; + }[] + )[0]; + expect(doc._vectors).toEqual({ + "potion-multilingual-128M": null, + }); + }); + }); + + describe("search", () => { + test("performs hybrid search with embedding", async () => { + const mockSearchResult = { + hits: [{ id: 1, name: "Test Singer" }], + query: "test", + processingTimeMs: 30, + offset: 1, + limit: 1, + estimatedTotalHits: 1, + }; + mockSearchIndex.search.mockResolvedValue(mockSearchResult); + + const result = await service.search("test query", "zh"); + + expect(mockGetSearchIndex).toHaveBeenCalledWith("singer_zh"); + expect(mockEmbeddingsPost).toHaveBeenCalledWith({ texts: ["test query"] }); + expect(mockSearchIndex.search).toHaveBeenCalledWith("test query", { + vector: [0.1, 0.2, 0.3], + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + expect(result).toEqual(mockSearchResult); + }); + + test("uses default language when not specified", async () => { + mockSearchIndex.search.mockResolvedValue({ hits: [] }); + + await service.search("test"); + + expect(mockGetSearchIndex).toHaveBeenCalledWith("singer_zh"); + }); + + test("throws when search manager not available", async () => { + const serviceWithoutManager = new SingerSearchService( + mockRepository, + undefined as never, + mockEmbeddingManager as never + ); + + expect(serviceWithoutManager.search("test")).rejects.toThrow( + "Search or embedding service not available" + ); + }); + + test("handles missing embedding response gracefully", async () => { + mockEmbeddingsPost.mockResolvedValue({ data: null }); + mockSearchIndex.search.mockResolvedValue({ hits: [] }); + + await service.search("test", "zh"); + + expect(mockSearchIndex.search).toHaveBeenCalledWith("test", { + vector: undefined, + hybrid: { + embedder: "potion-multilingual-128M", + semanticRatio: 0.25, + }, + showRankingScore: true, + }); + }); + }); +}); diff --git a/packages/core/tests/unit/SingerService.test.ts b/packages/core/tests/unit/SingerService.test.ts new file mode 100644 index 0000000..437e267 --- /dev/null +++ b/packages/core/tests/unit/SingerService.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, mock, test } from "bun:test"; +import { prisma } from "@cvsa/db"; +import { AppError } from "../../src/error/AppError"; +import { SingerService } from "../../src/modules/catalog/singer/service"; +import type { SingerDetailsResponseDto } from "../../src/modules/catalog/singer/dto"; +import type { ISingerRepository } from "../../src/modules/catalog/singer/repository.interface"; +import type { OutboxService } from "../../src/modules/outbox/service"; +import { createMockRepository } from "../utils"; + +( + prisma as unknown as { + $transaction: (fn: (tx: unknown) => Promise) => Promise; + } +).$transaction = mock(async (fn: (tx: unknown) => Promise) => fn(prisma)); + +const mockSingerDetails: SingerDetailsResponseDto = { + id: 1, + name: "Test Singer", + avatarUrl: null, + language: "zh", + localizedNames: null, + description: "A test singer", + localizedDescriptions: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const mockOutboxEntry = { + id: 1, + aggregateType: "singer" as const, + aggregateId: 1, + eventType: "singer.created" as const, + payload: null, + status: "PENDING" as const, + retryCount: 0, + lastError: null, + nextRetryAt: null, + createdAt: new Date().toISOString(), + processedAt: null, +}; + +describe("SingerService", () => { + const mockRepository = createMockRepository({ + getById: async (id: number) => { + if (id === 1) { + return mockSingerDetails; + } + return null; + }, + getDetailsById: async (id: number) => { + if (id === 1) { + return mockSingerDetails; + } + return null; + }, + create: async () => mockSingerDetails, + update: async () => mockSingerDetails, + softDelete: async () => {}, + }); + + const mockOutboxService = { + createEntry: mock(async () => mockOutboxEntry), + enqueue: mock(async () => {}), + processEntry: mock(async () => {}), + recoverStaleEntries: mock(async () => {}), + }; + + const singerService = new SingerService( + mockRepository as unknown as ISingerRepository, + mockOutboxService as unknown as OutboxService + ); + + describe("getDetails", () => { + test("returns singer details when singer exists", async () => { + const result = await singerService.getDetails(1); + + expect(result).toEqual(mockSingerDetails); + expect(mockRepository.getDetailsById).toHaveBeenCalledWith(1); + }); + + test("throws NOT_FOUND error when singer does not exist", async () => { + mockRepository.getDetailsById.mockResolvedValueOnce(null); + + expect(singerService.getDetails(999)).rejects.toThrow(AppError); + expect(singerService.getDetails(999)).rejects.toThrow("error.singer.notfound"); + expect(singerService.getDetails(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("create", () => { + const createInput = { + name: "New Singer", + language: "ja", + }; + + test("creates singer and calls outbox.createEntry and outbox.enqueue", async () => { + const result = await singerService.create(createInput); + + expect(result).toMatchObject({ + name: "Test Singer", + language: "zh", + }); + expect(mockRepository.create).toHaveBeenCalledWith(createInput, expect.anything()); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "singer", + aggregateId: mockSingerDetails.id, + eventType: "singer.created", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + }); + + describe("update", () => { + const updateInput = { name: "Updated Singer" }; + + test("updates singer and calls outbox.createEntry and outbox.enqueue on success", async () => { + const result = await singerService.update(1, updateInput); + + expect(result).toMatchObject({ + name: "Test Singer", + language: "zh", + }); + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.update).toHaveBeenCalledWith(1, updateInput, expect.anything()); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "singer", + aggregateId: 1, + eventType: "singer.updated", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + + test("throws NOT_FOUND error when singer does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(singerService.update(999, updateInput)).rejects.toThrow(AppError); + expect(singerService.update(999, updateInput)).rejects.toThrow("error.singer.notfound"); + expect(singerService.update(999, updateInput)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); + + describe("delete", () => { + test("soft deletes singer and calls outbox.createEntry and outbox.enqueue on success", async () => { + await singerService.delete(1); + + expect(mockRepository.getById).toHaveBeenCalledWith(1); + expect(mockRepository.softDelete).toHaveBeenCalledWith(1, expect.anything()); + expect(mockOutboxService.createEntry).toHaveBeenCalledWith( + { + aggregateType: "singer", + aggregateId: 1, + eventType: "singer.deleted", + }, + expect.anything() + ); + expect(mockOutboxService.enqueue).toHaveBeenCalledWith(mockOutboxEntry); + }); + + test("throws NOT_FOUND error when singer does not exist", async () => { + mockRepository.getById.mockResolvedValueOnce(null); + + expect(singerService.delete(999)).rejects.toThrow(AppError); + expect(singerService.delete(999)).rejects.toThrow("error.singer.notfound"); + expect(singerService.delete(999)).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + }); +}); From 620a33053a7b8509d78b0fd4db2fe32f094b8322 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Wed, 29 Apr 2026 22:10:01 +0800 Subject: [PATCH 4/4] format: reformat with Biome --- apps/backend/src/handlers/catalog/singer/create.ts | 3 +-- apps/backend/src/index.ts | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/handlers/catalog/singer/create.ts b/apps/backend/src/handlers/catalog/singer/create.ts index 5e405e4..42c6195 100644 --- a/apps/backend/src/handlers/catalog/singer/create.ts +++ b/apps/backend/src/handlers/catalog/singer/create.ts @@ -22,8 +22,7 @@ export const singerCreateHandler = new Elysia({ name: "singerCreateHandler" }) body: CreateSingerRequestSchema, detail: { summary: "Create Singer", - description: - "Create a new singer entry in the catalog. Requires authentication.", + description: "Create a new singer entry in the catalog. Requires authentication.", }, response: { 201: SingerResponseSchema, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index c164a0b..ae61139 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,7 +2,13 @@ import { Elysia } from "elysia"; import { onAfterHandler } from "./onAfterHandle"; import { getBindingInfo, logStartup } from "./startMessage"; import pkg from "../package.json"; -import { authHandler, songHandler, engineHandler, artistHandler, singerHandler } from "@handlers/index"; +import { + authHandler, + songHandler, + engineHandler, + artistHandler, + singerHandler, +} from "@handlers/index"; import { errorHandler } from "./errorHandler"; import { openapi } from "@elysiajs/openapi"; import { requestLoggerMiddleware } from "@/middlewares";