chore: upgrade Node.js runtime to v24 across codebase #419

Merged
jason.woltje merged 438 commits from fix/auth-frontend-remediation into main 2026-02-17 01:04:47 +00:00
4 changed files with 338 additions and 14 deletions
Showing only changes of commit bb6e08208c - Show all commits

View File

@@ -4,7 +4,14 @@ export { EntryQueryDto } from "./entry-query.dto";
export { CreateTagDto } from "./create-tag.dto";
export { UpdateTagDto } from "./update-tag.dto";
export { RestoreVersionDto } from "./restore-version.dto";
export { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./search-query.dto";
export {
SearchQueryDto,
TagSearchDto,
RecentEntriesDto,
SemanticSearchBodyDto,
SemanticSearchQueryDto,
HybridSearchBodyDto,
} from "./search-query.dto";
export { GraphQueryDto, GraphFilterDto } from "./graph-query.dto";
export { ExportQueryDto, ExportFormat } from "./import-export.dto";
export type { ImportResult, ImportResponseDto } from "./import-export.dto";

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum } from "class-validator";
import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum, MaxLength } from "class-validator";
import { Type, Transform } from "class-transformer";
import { EntryStatus } from "@prisma/client";
@@ -75,3 +75,49 @@ export class RecentEntriesDto {
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
status?: EntryStatus;
}
/**
* DTO for semantic search request body
* Validates the query string and optional status filter
*/
export class SemanticSearchBodyDto {
@IsString({ message: "query must be a string" })
@MaxLength(500, { message: "query must not exceed 500 characters" })
query!: string;
@IsOptional()
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
status?: EntryStatus;
}
/**
* DTO for semantic/hybrid search query parameters (pagination)
*/
export class SemanticSearchQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}
/**
* DTO for hybrid search request body
* Validates the query string and optional status filter
*/
export class HybridSearchBodyDto {
@IsString({ message: "query must be a string" })
@MaxLength(500, { message: "query must not exceed 500 characters" })
query!: string;
@IsOptional()
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
status?: EntryStatus;
}

View File

@@ -1,10 +1,13 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { EntryStatus } from "@prisma/client";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { SearchController } from "./search.controller";
import { SearchService } from "./services/search.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { SemanticSearchBodyDto, SemanticSearchQueryDto, HybridSearchBodyDto } from "./dto";
describe("SearchController", () => {
let controller: SearchController;
@@ -15,6 +18,8 @@ describe("SearchController", () => {
search: vi.fn(),
searchByTags: vi.fn(),
recentEntries: vi.fn(),
semanticSearch: vi.fn(),
hybridSearch: vi.fn(),
};
beforeEach(async () => {
@@ -217,4 +222,266 @@ describe("SearchController", () => {
);
});
});
describe("semanticSearch", () => {
it("should call searchService.semanticSearch with correct parameters", async () => {
const mockResult = {
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query: "machine learning",
};
mockSearchService.semanticSearch.mockResolvedValue(mockResult);
const body = plainToInstance(SemanticSearchBodyDto, {
query: "machine learning",
});
const query = plainToInstance(SemanticSearchQueryDto, {
page: 1,
limit: 20,
});
const result = await controller.semanticSearch(mockWorkspaceId, body, query);
expect(mockSearchService.semanticSearch).toHaveBeenCalledWith(
"machine learning",
mockWorkspaceId,
{
status: undefined,
page: 1,
limit: 20,
}
);
expect(result).toEqual(mockResult);
});
it("should pass status filter from body to service", async () => {
mockSearchService.semanticSearch.mockResolvedValue({
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query: "test",
});
const body = plainToInstance(SemanticSearchBodyDto, {
query: "test",
status: EntryStatus.PUBLISHED,
});
const query = plainToInstance(SemanticSearchQueryDto, {});
await controller.semanticSearch(mockWorkspaceId, body, query);
expect(mockSearchService.semanticSearch).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: EntryStatus.PUBLISHED,
page: undefined,
limit: undefined,
});
});
});
describe("hybridSearch", () => {
it("should call searchService.hybridSearch with correct parameters", async () => {
const mockResult = {
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query: "deep learning",
};
mockSearchService.hybridSearch.mockResolvedValue(mockResult);
const body = plainToInstance(HybridSearchBodyDto, {
query: "deep learning",
});
const query = plainToInstance(SemanticSearchQueryDto, {
page: 2,
limit: 10,
});
const result = await controller.hybridSearch(mockWorkspaceId, body, query);
expect(mockSearchService.hybridSearch).toHaveBeenCalledWith(
"deep learning",
mockWorkspaceId,
{
status: undefined,
page: 2,
limit: 10,
}
);
expect(result).toEqual(mockResult);
});
it("should pass status filter from body to service", async () => {
mockSearchService.hybridSearch.mockResolvedValue({
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query: "test",
});
const body = plainToInstance(HybridSearchBodyDto, {
query: "test",
status: EntryStatus.DRAFT,
});
const query = plainToInstance(SemanticSearchQueryDto, {});
await controller.hybridSearch(mockWorkspaceId, body, query);
expect(mockSearchService.hybridSearch).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: EntryStatus.DRAFT,
page: undefined,
limit: undefined,
});
});
});
});
describe("SemanticSearchBodyDto validation", () => {
it("should pass with valid query", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, { query: "test search" });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with query and valid status", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, {
query: "test search",
status: EntryStatus.PUBLISHED,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when query is missing", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, {});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
});
it("should fail when query is not a string", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, { query: 12345 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
});
it("should fail when query exceeds 500 characters", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, {
query: "a".repeat(501),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
});
it("should pass when query is exactly 500 characters", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, {
query: "a".repeat(500),
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid status value", async () => {
const dto = plainToInstance(SemanticSearchBodyDto, {
query: "test",
status: "INVALID_STATUS",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const statusError = errors.find((e) => e.property === "status");
expect(statusError).toBeDefined();
});
});
describe("HybridSearchBodyDto validation", () => {
it("should pass with valid query", async () => {
const dto = plainToInstance(HybridSearchBodyDto, { query: "test search" });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with query and valid status", async () => {
const dto = plainToInstance(HybridSearchBodyDto, {
query: "hybrid search",
status: EntryStatus.DRAFT,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when query is missing", async () => {
const dto = plainToInstance(HybridSearchBodyDto, {});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
});
it("should fail when query exceeds 500 characters", async () => {
const dto = plainToInstance(HybridSearchBodyDto, {
query: "a".repeat(501),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
});
it("should fail with invalid status value", async () => {
const dto = plainToInstance(HybridSearchBodyDto, {
query: "test",
status: "NOT_A_STATUS",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const statusError = errors.find((e) => e.property === "status");
expect(statusError).toBeDefined();
});
});
describe("SemanticSearchQueryDto validation", () => {
it("should pass with valid page and limit", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, { page: 1, limit: 20 });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with no parameters (all optional)", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, {});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when page is less than 1", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, { page: 0 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError).toBeDefined();
});
it("should fail when limit exceeds 100", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, { limit: 101 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
});
it("should fail when limit is less than 1", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, { limit: 0 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
});
it("should fail when page is not an integer", async () => {
const dto = plainToInstance(SemanticSearchQueryDto, { page: 1.5 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError).toBeDefined();
});
});

View File

@@ -1,10 +1,16 @@
import { Controller, Get, Post, Body, Query, UseGuards } from "@nestjs/common";
import { SearchService, PaginatedSearchResults } from "./services/search.service";
import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto";
import {
SearchQueryDto,
TagSearchDto,
RecentEntriesDto,
SemanticSearchBodyDto,
SemanticSearchQueryDto,
HybridSearchBodyDto,
} from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { EntryStatus } from "@prisma/client";
import type { PaginatedEntries, KnowledgeEntryWithTags } from "./entities/knowledge-entry.entity";
/**
@@ -112,14 +118,13 @@ export class SearchController {
@RequirePermission(Permission.WORKSPACE_ANY)
async semanticSearch(
@Workspace() workspaceId: string,
@Body() body: { query: string; status?: EntryStatus },
@Query("page") page?: number,
@Query("limit") limit?: number
@Body() body: SemanticSearchBodyDto,
@Query() query: SemanticSearchQueryDto
): Promise<PaginatedSearchResults> {
return this.searchService.semanticSearch(body.query, workspaceId, {
status: body.status,
page,
limit,
page: query.page,
limit: query.limit,
});
}
@@ -138,14 +143,13 @@ export class SearchController {
@RequirePermission(Permission.WORKSPACE_ANY)
async hybridSearch(
@Workspace() workspaceId: string,
@Body() body: { query: string; status?: EntryStatus },
@Query("page") page?: number,
@Query("limit") limit?: number
@Body() body: HybridSearchBodyDto,
@Query() query: SemanticSearchQueryDto
): Promise<PaginatedSearchResults> {
return this.searchService.hybridSearch(body.query, workspaceId, {
status: body.status,
page,
limit,
page: query.page,
limit: query.limit,
});
}
}