diff --git a/api/src/tags/slugify.spec.ts b/api/src/tags/slugify.spec.ts new file mode 100644 index 0000000..8832340 --- /dev/null +++ b/api/src/tags/slugify.spec.ts @@ -0,0 +1,58 @@ +import { slugify } from "./slugify" + +describe("slugify", () => { + it("lowercases and replaces spaces with dashes", () => { + expect(slugify("Live Streaming")).toBe("live-streaming") + }) + + it("strips leading/trailing whitespace", () => { + expect(slugify(" hello world ")).toBe("hello-world") + }) + + it("strips diacritics via NFD normalisation", () => { + expect(slugify("Café")).toBe("cafe") + expect(slugify("Café / Brunch")).toBe("cafe-brunch") + }) + + it("collapses multiple non-alphanumeric runs into a single dash", () => { + expect(slugify("a --- b")).toBe("a-b") + }) + + it("removes leading and trailing dashes", () => { + expect(slugify("!!!hello!!!")).toBe("hello") + }) + + it("handles purely punctuation/symbol input → empty string", () => { + expect(slugify("!!!")).toBe("") + expect(slugify("---")).toBe("") + }) + + it("returns empty string for empty input", () => { + expect(slugify("")).toBe("") + }) + + it("returns empty string for non-string input", () => { + // @ts-expect-error intentional type violation + expect(slugify(null)).toBe("") + // @ts-expect-error intentional type violation + expect(slugify(undefined)).toBe("") + }) + + it("strips C++ style special chars, leaving alphanumeric", () => { + expect(slugify("C++")).toBe("c") + }) + + it("caps slug length at 64 characters", () => { + const long = "a".repeat(100) + expect(slugify(long)).toHaveLength(64) + }) + + it("handles unicode letters that have no combining marks", () => { + // Chinese characters have no combining marks; they become dashes + expect(slugify("tag-你好")).toBe("tag") + }) + + it("preserves numbers", () => { + expect(slugify("Stream 2024")).toBe("stream-2024") + }) +}) diff --git a/api/src/tags/tags.controller.spec.ts b/api/src/tags/tags.controller.spec.ts new file mode 100644 index 0000000..e27827d --- /dev/null +++ b/api/src/tags/tags.controller.spec.ts @@ -0,0 +1,137 @@ +// Mock the env config before any imports that transitively load it. +// StreamOwnershipGuard → StreamOwnershipService → config/env → validateEnv() +// which calls process.exit(1) when DATABASE_URL / STREAM_API_KEY are absent. +jest.mock("../config/env", () => ({ + env: { + PORT: "3001", + NODE_ENV: "test", + DATABASE_URL: "postgres://localhost/test", + JWT_SECRET: "test-secret", + STREAM_API_KEY: "test-key", + }, +})) + +import { Test, TestingModule } from "@nestjs/testing" +import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" +import { StreamTagsController, TagsListController } from "./tags.controller" +import { TagsService } from "./tags.service" +import { Tag } from "./tag.entity" + +const makeTag = (overrides: Partial = {}): Tag => ({ + id: 1, + name: "Live Streaming", + slug: "live-streaming", + createdAt: new Date(), + ...overrides, +}) + +describe("TagsListController", () => { + let controller: TagsListController + let tagsService: jest.Mocked> + + beforeEach(async () => { + tagsService = { list: jest.fn() } + + const module: TestingModule = await Test.createTestingModule({ + controllers: [TagsListController], + providers: [{ provide: TagsService, useValue: tagsService }], + }).compile() + + controller = module.get(TagsListController) + }) + + it("calls tagsService.list with defaults when no query is provided", async () => { + tagsService.list.mockResolvedValue({ + data: [], + page: 1, + limit: 20, + total: 0, + hasMore: false, + }) + + await controller.list({}) + + expect(tagsService.list).toHaveBeenCalledWith(1, 20) + }) + + it("forwards explicit page and limit from query", async () => { + tagsService.list.mockResolvedValue({ + data: [], + page: 2, + limit: 10, + total: 0, + hasMore: false, + }) + + await controller.list({ page: 2, limit: 10 }) + + expect(tagsService.list).toHaveBeenCalledWith(2, 10) + }) + + it("returns whatever tagsService.list returns", async () => { + const tag = makeTag() + const payload = { data: [tag], page: 1, limit: 20, total: 1, hasMore: false } + tagsService.list.mockResolvedValue(payload) + + const result = await controller.list({}) + + expect(result).toEqual(payload) + }) +}) + +describe("StreamTagsController", () => { + let controller: StreamTagsController + let tagsService: jest.Mocked> + + beforeEach(async () => { + tagsService = { + attachToStream: jest.fn(), + detachFromStream: jest.fn(), + } + + const module: TestingModule = await Test.createTestingModule({ + controllers: [StreamTagsController], + providers: [{ provide: TagsService, useValue: tagsService }], + }) + .overrideGuard(StreamOwnershipGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(StreamTagsController) + }) + + // ── @UseGuards reflection ───────────────────────────────────────────── + + it("has StreamOwnershipGuard applied to the controller class", () => { + const guards: unknown[] = + Reflect.getMetadata("__guards__", StreamTagsController) ?? [] + expect(guards).toContain(StreamOwnershipGuard) + }) + + // ── attach ───────────────────────────────────────────────────────────── + + it("delegates attach to tagsService.attachToStream", async () => { + const tag = makeTag() + tagsService.attachToStream.mockResolvedValue(tag) + + const result = await controller.attach(1, { name: "Live Streaming" }) + + expect(tagsService.attachToStream).toHaveBeenCalledWith(1, "Live Streaming") + expect(result).toEqual(tag) + }) + + // ── detach ───────────────────────────────────────────────────────────── + + it("delegates detach to tagsService.detachFromStream", async () => { + tagsService.detachFromStream.mockResolvedValue(undefined) + + await controller.detach(1, 7) + + expect(tagsService.detachFromStream).toHaveBeenCalledWith(1, 7) + }) + + it("resolves void on successful detach", async () => { + tagsService.detachFromStream.mockResolvedValue(undefined) + await expect(controller.detach(1, 7)).resolves.toBeUndefined() + }) +}) diff --git a/api/src/tags/tags.service.spec.ts b/api/src/tags/tags.service.spec.ts new file mode 100644 index 0000000..eef7215 --- /dev/null +++ b/api/src/tags/tags.service.spec.ts @@ -0,0 +1,165 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common" +import { Test, TestingModule } from "@nestjs/testing" +import { TagsRepository } from "./repository/tags.repository" +import { Tag } from "./tag.entity" +import { TagsService } from "./tags.service" + +const makeTag = (overrides: Partial = {}): Tag => ({ + id: 1, + name: "Live Streaming", + slug: "live-streaming", + createdAt: new Date(), + ...overrides, +}) + +describe("TagsService", () => { + let service: TagsService + let repo: jest.Mocked + + beforeEach(async () => { + const mockRepo: jest.Mocked = { + listPaginated: jest.fn(), + findBySlug: jest.fn(), + findById: jest.fn(), + upsertBySlug: jest.fn(), + attachToStream: jest.fn(), + detachFromStream: jest.fn(), + isAttached: jest.fn(), + } as unknown as jest.Mocked + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TagsService, + { provide: TagsRepository, useValue: mockRepo }, + ], + }).compile() + + service = module.get(TagsService) + repo = module.get(TagsRepository) + }) + + // ── list ──────────────────────────────────────────────────────────────── + + describe("list", () => { + it("returns paginated data with hasMore=true when more pages exist", async () => { + const tag = makeTag() + repo.listPaginated.mockResolvedValue({ items: [tag], total: 25 }) + + const result = await service.list(1, 20) + + expect(result.data).toEqual([tag]) + expect(result.total).toBe(25) + expect(result.page).toBe(1) + expect(result.limit).toBe(20) + expect(result.hasMore).toBe(true) + }) + + it("returns hasMore=false on the last page", async () => { + repo.listPaginated.mockResolvedValue({ items: [], total: 20 }) + const result = await service.list(1, 20) + expect(result.hasMore).toBe(false) + }) + + it("delegates to TagsRepository.listPaginated with correct args", async () => { + repo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + await service.list(3, 10) + expect(repo.listPaginated).toHaveBeenCalledWith(3, 10) + }) + }) + + // ── attachToStream ─────────────────────────────────────────────────────── + + describe("attachToStream", () => { + it("creates a new tag and attaches it to the stream", async () => { + const tag = makeTag() + repo.upsertBySlug.mockResolvedValue(tag) + repo.attachToStream.mockResolvedValue({ + streamId: 1, + tagId: tag.id, + createdAt: new Date(), + }) + + const result = await service.attachToStream(1, "Live Streaming") + + expect(repo.upsertBySlug).toHaveBeenCalledWith("Live Streaming", "live-streaming") + expect(repo.attachToStream).toHaveBeenCalledWith(1, tag.id) + expect(result).toEqual(tag) + }) + + it("reuses an existing tag when the slug already exists", async () => { + const existing = makeTag({ id: 42 }) + repo.upsertBySlug.mockResolvedValue(existing) + repo.attachToStream.mockResolvedValue({ + streamId: 5, + tagId: 42, + createdAt: new Date(), + }) + + const result = await service.attachToStream(5, "Live Streaming") + + expect(repo.upsertBySlug).toHaveBeenCalledTimes(1) + expect(result.id).toBe(42) + }) + + it("trims whitespace from name before slugifying", async () => { + const tag = makeTag() + repo.upsertBySlug.mockResolvedValue(tag) + repo.attachToStream.mockResolvedValue({ + streamId: 1, + tagId: tag.id, + createdAt: new Date(), + }) + + await service.attachToStream(1, " Live Streaming ") + + expect(repo.upsertBySlug).toHaveBeenCalledWith("Live Streaming", "live-streaming") + }) + + it("throws BadRequestException for empty name", async () => { + await expect(service.attachToStream(1, "")).rejects.toThrow(BadRequestException) + }) + + it("throws BadRequestException for non-alphanumeric name", async () => { + await expect(service.attachToStream(1, "!!!")).rejects.toThrow( + BadRequestException, + ) + }) + + it("throws BadRequestException with descriptive message", async () => { + await expect(service.attachToStream(1, "---")).rejects.toThrow( + "name must contain at least one alphanumeric character", + ) + }) + }) + + // ── detachFromStream ───────────────────────────────────────────────────── + + describe("detachFromStream", () => { + it("detaches a tag that is currently attached to the stream", async () => { + const tag = makeTag({ id: 7 }) + repo.findById.mockResolvedValue(tag) + repo.detachFromStream.mockResolvedValue(true) + + await expect(service.detachFromStream(1, 7)).resolves.toBeUndefined() + expect(repo.detachFromStream).toHaveBeenCalledWith(1, 7) + }) + + it("throws NotFoundException when the tag does not exist", async () => { + repo.findById.mockResolvedValue(undefined) + + await expect(service.detachFromStream(1, 99)).rejects.toThrow(NotFoundException) + await expect(service.detachFromStream(1, 99)).rejects.toThrow("tag 99 not found") + }) + + it("throws NotFoundException when tag is not attached to the stream", async () => { + const tag = makeTag({ id: 7 }) + repo.findById.mockResolvedValue(tag) + repo.detachFromStream.mockResolvedValue(false) + + await expect(service.detachFromStream(1, 7)).rejects.toThrow(NotFoundException) + await expect(service.detachFromStream(1, 7)).rejects.toThrow( + "tag 7 is not attached to stream 1", + ) + }) + }) +})