Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions api/src/tags/slugify.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
137 changes: 137 additions & 0 deletions api/src/tags/tags.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Tag => ({
id: 1,
name: "Live Streaming",
slug: "live-streaming",
createdAt: new Date(),
...overrides,
})

describe("TagsListController", () => {
let controller: TagsListController
let tagsService: jest.Mocked<Pick<TagsService, "list">>

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<Pick<TagsService, "attachToStream" | "detachFromStream">>

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()
})
})
165 changes: 165 additions & 0 deletions api/src/tags/tags.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Tag => ({
id: 1,
name: "Live Streaming",
slug: "live-streaming",
createdAt: new Date(),
...overrides,
})

describe("TagsService", () => {
let service: TagsService
let repo: jest.Mocked<TagsRepository>

beforeEach(async () => {
const mockRepo: jest.Mocked<TagsRepository> = {
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<TagsRepository>

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",
)
})
})
})
Loading