Skip to content
Merged
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
15 changes: 13 additions & 2 deletions api/src/contract-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ import {
type Contract,
} from "@xstreamroll/contract-tests"
import request from "supertest"

import { AuditService } from "./audit/audit.service"
import { AuthController } from "./auth/auth.controller"
import { AuthService } from "./auth/auth.service"
import { PasswordResetService } from "./auth/password-reset.service"
import { TokenDenylistService } from "./auth/token-denylist.service"
import { User, UsersRepository } from "./auth/users.repository"
import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config"
import { AuthGuard } from "./common/guards/auth.guard"
import { JwtExtractorService } from "./common/guards/jwt-extractor.service"
import { StreamOwnershipGuard } from "./common/guards/stream-ownership.guard"
import { StreamOwnershipService } from "./common/guards/stream-ownership.service"
import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config"
import { StreamsRepository } from "./streams/repository/streams.repository"
import { StreamsController } from "./streams/streams.controller"
import { StreamsService } from "./streams/streams.service"
import { TagsRepository } from "./tags/repository/tags.repository"
import { StreamTagsController } from "./tags/tags.controller"
import { TagsService } from "./tags/tags.service"
import { WebhooksService } from "./webhooks/webhooks.service"

Expand Down Expand Up @@ -108,7 +110,7 @@ describe("Contract provider verification (api)", () => {
JwtModule.registerAsync({ useFactory: () => createJwtConfig() }),
CacheModule.register(),
],
controllers: [StreamsController, AuthController],
controllers: [StreamsController, StreamTagsController, AuthController],
providers: [
StreamsService,
TagsService,
Expand Down Expand Up @@ -176,6 +178,15 @@ describe("Contract provider verification (api)", () => {
description: "Seeded for contract verification",
})
existingStreamId = String(stream.id)

// Attach one tag so `list-stream-tags` exercises a non-empty
// response (the schema pins the Tag shape, not just the empty case).
const tagsRepository = moduleFixture.get(TagsRepository)
const seededTag = await tagsRepository.upsertBySlug(
"Live Streaming",
"live-streaming",
)
await tagsRepository.attachToStream(stream.id, seededTag.id)
})

afterAll(async () => {
Expand Down
43 changes: 41 additions & 2 deletions api/src/tags/tags.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Test, TestingModule } from "@nestjs/testing"

import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"
import { Tag } from "./tag.entity"
import { StreamTagsController, TagsListController } from "./tags.controller"
import { TagsService } from "./tags.service"
import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"

// Mock the env config before any imports that transitively load it.
// StreamOwnershipGuard → StreamOwnershipService → config/env → validateEnv()
Expand Down Expand Up @@ -89,11 +89,12 @@ describe("TagsListController", () => {
describe("StreamTagsController", () => {
let controller: StreamTagsController
let tagsService: jest.Mocked<
Pick<TagsService, "attachToStream" | "detachFromStream">
Pick<TagsService, "listForStream" | "attachToStream" | "detachFromStream">
>

beforeEach(async () => {
tagsService = {
listForStream: jest.fn(),
attachToStream: jest.fn(),
detachFromStream: jest.fn(),
}
Expand All @@ -109,6 +110,44 @@ describe("StreamTagsController", () => {
controller = module.get(StreamTagsController)
})

// ── list (issue #517) ──────────────────────────────────────────────────

it("delegates list to tagsService.listForStream with defaults", async () => {
const tag = makeTag()
tagsService.listForStream.mockResolvedValue({
data: [tag],
page: 1,
limit: 50,
total: 1,
hasMore: false,
})

const result = await controller.list(1, {})

expect(tagsService.listForStream).toHaveBeenCalledWith(1, 1, 50)
expect(result).toEqual({
data: [tag],
page: 1,
limit: 50,
total: 1,
hasMore: false,
})
})

it("forwards explicit page and limit from query", async () => {
tagsService.listForStream.mockResolvedValue({
data: [],
page: 2,
limit: 10,
total: 0,
hasMore: false,
})

await controller.list(1, { page: 2, limit: 10 })

expect(tagsService.listForStream).toHaveBeenCalledWith(1, 2, 10)
})

// ── @UseGuards reflection ─────────────────────────────────────────────

it("has StreamOwnershipGuard applied to the controller class", () => {
Expand Down
32 changes: 30 additions & 2 deletions api/src/tags/tags.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import {
Query,
UseGuards,
} from "@nestjs/common"
import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"
import {
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
} from "@nestjs/swagger"

import { CreateTagDto } from "./dto/create-tag.dto"
import { ListTagsQueryDto } from "./dto/list-tags.query.dto"
import { TagsService } from "./tags.service"
import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard"

/**
* Public, paginated list of all tags in the system.
Expand All @@ -34,9 +40,10 @@ export class TagsListController {
}

/**
* Stream-scoped tag management. Both endpoints require ownership of the
* Stream-scoped tag management. All endpoints require ownership of the
* referenced stream, enforced via {@link StreamOwnershipGuard}.
*
* GET /streams/:id/tags -> PagedTags
* POST /streams/:id/tags { name: "Live Streaming" }
* DELETE /streams/:id/tags/:tagId
*/
Expand All @@ -45,6 +52,27 @@ export class TagsListController {
export class StreamTagsController {
constructor(private readonly tagsService: TagsService) {}

/**
* Lists the tags attached to a stream (issue #517). The response
* uses the same `PagedTags` envelope as `GET /tags` so the dashboard's
* `useStreamTags` hook can parse it without a second shape. Ownership
* is enforced by {@link StreamOwnershipGuard} — non-owners get 403.
*/
@Get()
@ApiOperation({ summary: "List tags attached to a stream" })
@ApiOkResponse({
description: "The tags attached to the stream, in a PagedTags envelope",
})
@ApiForbiddenResponse({ description: "You do not own this stream." })
list(
@Param("id", ParseIntPipe) streamId: number,
@Query() query: ListTagsQueryDto,
) {
const page = query.page ?? 1
const limit = query.limit ?? 50
return this.tagsService.listForStream(streamId, page, limit)
}

@Post()
@HttpCode(HttpStatus.CREATED)
attach(
Expand Down
47 changes: 47 additions & 0 deletions api/src/tags/tags.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,53 @@ describe("TagsService", () => {
})
})

// ── listForStream (issue #517) ──────────────────────────────────────────

describe("listForStream", () => {
it("returns the stream's tags in the PagedTags envelope via the batch path", async () => {
const tag = makeTag()
repo.listForStreamIds.mockResolvedValue(new Map([[7, [tag]]]))

const result = await service.listForStream(7)

// Reuses the batch loader with a single-element array — no per-tag
// query is issued.
expect(repo.listForStreamIds).toHaveBeenCalledWith([7])
expect(result).toEqual({
data: [tag],
page: 1,
limit: 50,
total: 1,
hasMore: false,
})
})

it("returns an empty list (not an error) for a stream with no tags", async () => {
repo.listForStreamIds.mockResolvedValue(new Map([[7, []]]))

const result = await service.listForStream(7)

expect(result.data).toEqual([])
expect(result.total).toBe(0)
expect(result.hasMore).toBe(false)
})

it("paginates the page slice and sets hasMore", async () => {
const tags = Array.from({ length: 3 }, (_, i) =>
makeTag({ id: i + 1, slug: `slug-${i + 1}` }),
)
repo.listForStreamIds.mockResolvedValue(new Map([[7, tags]]))

const page1 = await service.listForStream(7, 1, 2)
expect(page1.data).toHaveLength(2)
expect(page1.hasMore).toBe(true)

const page2 = await service.listForStream(7, 2, 2)
expect(page2.data).toHaveLength(1)
expect(page2.hasMore).toBe(false)
})
})

// ── detachFromStream ─────────────────────────────────────────────────────

describe("detachFromStream", () => {
Expand Down
25 changes: 24 additions & 1 deletion api/src/tags/tags.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common"
import { PaginatedResult } from "../common/dto/pagination.dto"

import { TagsRepository } from "./repository/tags.repository"
import { slugify } from "./slugify"
import { Tag } from "./tag.entity"
import { PaginatedResult } from "../common/dto/pagination.dto"

export interface PagedTags extends PaginatedResult<Tag> {
hasMore: boolean
Expand Down Expand Up @@ -42,6 +43,28 @@ export class TagsService {
return this.tags.listForStreamIds(streamIds)
}

/**
* Loads the tags attached to a single stream, reusing the batch
* `listForStreamIds` path (issue #330) so the DB cost stays at one
* round-trip instead of a per-tag query. Returns the `PagedTags`
* envelope the app's `useStreamTags` hook parses (issue #517).
*/
async listForStream(
streamId: number,
page = 1,
limit = 50,
): Promise<PagedTags> {
const tags = (await this.listForStreamIds([streamId])).get(streamId) ?? []
const offset = (page - 1) * limit
return {
data: tags.slice(offset, offset + limit),
page,
limit,
total: tags.length,
hasMore: page * limit < tags.length,
}
}

/**
* Create-or-fetch a tag from a raw name, then attach it to the stream.
* Returns the canonical Tag row (existing or freshly created).
Expand Down
42 changes: 40 additions & 2 deletions app/hooks/useStreams.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as React from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { renderHook, waitFor } from "@testing-library/react"
import { useStreamList, streamKeys } from "@/hooks/useStreams"
import * as React from "react"

import { useStreamList, useStreamTags, streamKeys } from "@/hooks/useStreams"

function createWrapper() {
const client = new QueryClient({
Expand Down Expand Up @@ -70,3 +71,40 @@ describe("useStreamList (issue #345 phase B)", () => {
expect(result.current.data?.data[0]?.tags).toEqual([])
})
})

describe("useStreamTags (issue #517)", () => {
it("fetches GET /streams/:id/tags and parses the PagedTags envelope", async () => {
const mockResponse = {
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({
data: [
{
id: 1,
name: "Live Streaming",
slug: "live-streaming",
createdAt: "2026-01-01T00:00:00.000Z",
},
],
page: 1,
limit: 50,
total: 1,
hasMore: false,
}),
}
const mock = jest.fn().mockResolvedValue(mockResponse)
global.fetch = mock as unknown as typeof fetch

const { result } = renderHook(() => useStreamTags(42), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mock).toHaveBeenCalledTimes(1)
const calledUrl = (mock.mock.calls[0]?.[0] as URL | string).toString()
expect(calledUrl).toMatch(/\/streams\/42\/tags$/)
expect(result.current.data?.data[0]?.name).toBe("Live Streaming")
expect(result.current.data?.hasMore).toBe(false)
})
})
25 changes: 25 additions & 0 deletions tests/contracts/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { z, type ZodType } from "zod"

import type {
ApiErrorResponse,
PaginatedResponse,
Stream,
Tag,
User,
} from "@xstreamroll/types"

Expand Down Expand Up @@ -44,6 +46,29 @@ export const paginatedStreamsSchema = typed<PaginatedResponse<Stream>>()(
}),
)

export const tagSchema = typed<Tag>()(
z.object({
id: z.number(),
name: z.string(),
slug: z.string(),
createdAt: z.string(),
}),
)

/**
* Envelope returned by `GET /streams/:id/tags` (issue #517). Uses the
* `PagedTags` shape (with `hasMore`) that the app's `useStreamTags`
* hook parses — deliberately not pinned via `typed<>` because no
* shared `@xstreamroll/types` interface carries `hasMore` yet.
*/
export const pagedTagsSchema = z.object({
data: z.array(tagSchema),
page: z.number(),
limit: z.number(),
total: z.number(),
hasMore: z.boolean(),
})

export const userSchema = typed<User>()(
z.object({
id: z.string(),
Expand Down
Loading
Loading