From a8853058505e438473917e435675b472a687516c Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 9 Apr 2026 23:38:22 +0530 Subject: [PATCH 1/7] feat: move file listing to dedicated api --- app/api/files/route.ts | 32 ++++ lib/files/__tests__/getFilesHandler.test.ts | 178 ++++++++++++++++++ lib/files/__tests__/listFilesByArtist.test.ts | 91 +++++++++ .../__tests__/validateGetFilesQuery.test.ts | 143 ++++++++++++++ lib/files/filterFilesByPath.ts | 35 ++++ lib/files/getFilesHandler.ts | 62 ++++++ lib/files/listFilesByArtist.ts | 42 +++++ lib/files/validateGetFilesQuery.ts | 73 +++++++ lib/supabase/files/getFilesByArtistId.ts | 24 +++ 9 files changed, 680 insertions(+) create mode 100644 app/api/files/route.ts create mode 100644 lib/files/__tests__/getFilesHandler.test.ts create mode 100644 lib/files/__tests__/listFilesByArtist.test.ts create mode 100644 lib/files/__tests__/validateGetFilesQuery.test.ts create mode 100644 lib/files/filterFilesByPath.ts create mode 100644 lib/files/getFilesHandler.ts create mode 100644 lib/files/listFilesByArtist.ts create mode 100644 lib/files/validateGetFilesQuery.ts create mode 100644 lib/supabase/files/getFilesByArtistId.ts diff --git a/app/api/files/route.ts b/app/api/files/route.ts new file mode 100644 index 00000000..61f4c15c --- /dev/null +++ b/app/api/files/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getFilesHandler } from "@/lib/files/getFilesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/files + * + * Lists files for an accessible artist account. Returns the matching file rows + * along with the owner's primary email address when one exists. + * + * @param request - The request object containing query parameters. + * @returns A NextResponse with file rows or an error response. + */ +export async function GET(request: NextRequest) { + return getFilesHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/files/__tests__/getFilesHandler.test.ts b/lib/files/__tests__/getFilesHandler.test.ts new file mode 100644 index 00000000..b2e9adb1 --- /dev/null +++ b/lib/files/__tests__/getFilesHandler.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextResponse, type NextRequest } from "next/server"; +import { getFilesHandler } from "../getFilesHandler"; +import { validateGetFilesQuery } from "../validateGetFilesQuery"; +import { listFilesByArtist } from "../listFilesByArtist"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetFilesQuery", () => ({ + validateGetFilesQuery: vi.fn(), +})); + +vi.mock("../listFilesByArtist", () => ({ + listFilesByArtist: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +/** + * Creates a mock request for GET /api/files tests. + * + * @returns A mocked NextRequest instance. + */ +function createRequest(): NextRequest { + return { + url: "http://localhost/api/files", + headers: new Headers({ authorization: "Bearer token" }), + } as unknown as NextRequest; +} + +const baseFile = { + id: "550e8400-e29b-41d4-a716-446655440010", + owner_account_id: "550e8400-e29b-41d4-a716-446655440100", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + file_name: "report.md", + storage_key: + "files/550e8400-e29b-41d4-a716-446655440100/550e8400-e29b-41d4-a716-446655440000/report.md", + mime_type: "text/markdown", + is_directory: false, + size_bytes: 123, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", +}; + +describe("getFilesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation response errors directly", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await getFilesHandler(createRequest()); + + expect(result.status).toBe(401); + }); + + it("returns files with owner_email when one exists", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + requesterAccountId: "acc", + }); + vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-1", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "owner@example.com", + updated_at: "2026-04-09T00:00:00.000Z", + }, + ]); + + const result = await getFilesHandler(createRequest()); + const body = await result.json(); + + expect(listFilesByArtist).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440000", + undefined, + false, + ); + expect(selectAccountEmails).toHaveBeenCalledWith({ + accountIds: ["550e8400-e29b-41d4-a716-446655440100"], + }); + expect(result.status).toBe(200); + expect(body).toEqual({ + files: [{ ...baseFile, owner_email: "owner@example.com" }], + }); + }); + + it("returns owner_email as null when no email row exists", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + requesterAccountId: "acc", + }); + vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await getFilesHandler(createRequest()); + const body = await result.json(); + + expect(body).toEqual({ + files: [{ ...baseFile, owner_email: null }], + }); + }); + + it("deduplicates owner account IDs before fetching emails", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: true, + requesterAccountId: "acc", + }); + vi.mocked(listFilesByArtist).mockResolvedValue([ + baseFile, + { + ...baseFile, + id: "550e8400-e29b-41d4-a716-446655440011", + file_name: "second.md", + }, + ]); + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-1", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "owner@example.com", + updated_at: "2026-04-09T00:00:00.000Z", + }, + ]); + + await getFilesHandler(createRequest()); + + expect(selectAccountEmails).toHaveBeenCalledTimes(1); + expect(selectAccountEmails).toHaveBeenCalledWith({ + accountIds: ["550e8400-e29b-41d4-a716-446655440100"], + }); + }); + + it("returns an empty files array without fetching emails when no files match", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + requesterAccountId: "acc", + }); + vi.mocked(listFilesByArtist).mockResolvedValue([]); + + const result = await getFilesHandler(createRequest()); + const body = await result.json(); + + expect(selectAccountEmails).not.toHaveBeenCalled(); + expect(body).toEqual({ files: [] }); + }); + + it("returns 500 when file lookup throws", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + requesterAccountId: "acc", + }); + vi.mocked(listFilesByArtist).mockRejectedValue(new Error("db blew up")); + + const result = await getFilesHandler(createRequest()); + + expect(result.status).toBe(500); + await expect(result.json()).resolves.toMatchObject({ + error: "db blew up", + }); + }); +}); diff --git a/lib/files/__tests__/listFilesByArtist.test.ts b/lib/files/__tests__/listFilesByArtist.test.ts new file mode 100644 index 00000000..7930a427 --- /dev/null +++ b/lib/files/__tests__/listFilesByArtist.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listFilesByArtist } from "../listFilesByArtist"; +import { getFilesByArtistId } from "@/lib/supabase/files/getFilesByArtistId"; + +vi.mock("@/lib/supabase/files/getFilesByArtistId", () => ({ + getFilesByArtistId: vi.fn(), +})); + +const files = [ + { + id: "1", + owner_account_id: "owner-1", + artist_account_id: "artist-1", + file_name: "root.txt", + storage_key: "files/owner-1/artist-1/root.txt", + mime_type: "text/plain", + is_directory: false, + size_bytes: 10, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", + }, + { + id: "2", + owner_account_id: "owner-1", + artist_account_id: "artist-1", + file_name: "reports", + storage_key: "files/owner-1/artist-1/reports/", + mime_type: null, + is_directory: true, + size_bytes: null, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", + }, + { + id: "3", + owner_account_id: "owner-2", + artist_account_id: "artist-1", + file_name: "weekly.md", + storage_key: "files/owner-2/artist-1/reports/weekly.md", + mime_type: "text/markdown", + is_directory: false, + size_bytes: 20, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", + }, + { + id: "4", + owner_account_id: "owner-2", + artist_account_id: "artist-1", + file_name: "april.md", + storage_key: "files/owner-2/artist-1/reports/monthly/april.md", + mime_type: "text/markdown", + is_directory: false, + size_bytes: 20, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", + }, +]; + +describe("listFilesByArtist", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getFilesByArtistId).mockResolvedValue(files); + }); + + it("returns only root-level entries when no path is provided", async () => { + const result = await listFilesByArtist("artist-1"); + + expect(result.map(file => file.id)).toEqual(["1", "2"]); + }); + + it("returns only immediate children for a nested path", async () => { + const result = await listFilesByArtist("artist-1", "reports"); + + expect(result.map(file => file.id)).toEqual(["3"]); + }); + + it("returns all descendants when recursive is true", async () => { + const result = await listFilesByArtist("artist-1", "reports", true); + + expect(result.map(file => file.id)).toEqual(["3", "4"]); + }); +}); diff --git a/lib/files/__tests__/validateGetFilesQuery.test.ts b/lib/files/__tests__/validateGetFilesQuery.test.ts new file mode 100644 index 00000000..0c3d9f0d --- /dev/null +++ b/lib/files/__tests__/validateGetFilesQuery.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse, type NextRequest } from "next/server"; +import { validateGetFilesQuery } from "../validateGetFilesQuery"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +/** + * Creates a mock NextRequest for GET /api/files validation tests. + * + * @param url - Request URL. + * @returns A mocked NextRequest instance. + */ +function createRequest(url: string): NextRequest { + return { + url, + headers: new Headers({ authorization: "Bearer token" }), + } as unknown as NextRequest; +} + +describe("validateGetFilesQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth errors directly", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateGetFilesQuery(createRequest("http://localhost/api/files")); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when artist_account_id is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + + const result = await validateGetFilesQuery(createRequest("http://localhost/api/files")); + + expect((result as NextResponse).status).toBe(400); + await expect((result as NextResponse).json()).resolves.toMatchObject({ + error: "Invalid input: expected string, received undefined", + }); + }); + + it("returns 400 when artist_account_id is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + + const result = await validateGetFilesQuery( + createRequest("http://localhost/api/files?artist_account_id=not-a-uuid"), + ); + + expect((result as NextResponse).status).toBe(400); + await expect((result as NextResponse).json()).resolves.toMatchObject({ + error: "artist_account_id must be a valid UUID", + }); + }); + + it("returns 403 when requester cannot access the artist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000", + ), + ); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "account-123", + "550e8400-e29b-41d4-a716-446655440000", + ); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns validated params for direct artist access", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&path=reports&recursive=true", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + path: "reports", + recursive: true, + requesterAccountId: "account-123", + }); + }); + + it("returns validated params for shared-org artist access", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-456", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440001", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440001", + recursive: false, + requesterAccountId: "account-456", + }); + }); +}); diff --git a/lib/files/filterFilesByPath.ts b/lib/files/filterFilesByPath.ts new file mode 100644 index 00000000..d36d4eb4 --- /dev/null +++ b/lib/files/filterFilesByPath.ts @@ -0,0 +1,35 @@ +import type { Tables } from "@/types/database.types"; + +type FileRecord = Tables<"files">; + +/** + * Filters file rows to immediate children of the provided relative path. + * + * @param files - File rows to filter. + * @param path - Optional relative directory path. + * @returns The filtered file rows. + */ +export function filterFilesByPath(files: FileRecord[], path?: string): FileRecord[] { + return files.filter(file => { + const match = file.storage_key.match(/^files\/[^\/]+\/[^\/]+\/(.+)$/); + if (!match) return false; + + const relativePath = match[1]; + + if (path) { + const pathPrefix = path.endsWith("/") ? path : `${path}/`; + if (!relativePath.startsWith(pathPrefix)) return false; + + const relativeToFilter = relativePath.slice(pathPrefix.length); + const trimmed = relativeToFilter.endsWith("/") + ? relativeToFilter.slice(0, -1) + : relativeToFilter; + + return trimmed.length > 0 && !trimmed.includes("/"); + } + + const trimmed = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath; + + return trimmed.length > 0 && !trimmed.includes("/"); + }); +} diff --git a/lib/files/getFilesHandler.ts b/lib/files/getFilesHandler.ts new file mode 100644 index 00000000..e8ce2ad8 --- /dev/null +++ b/lib/files/getFilesHandler.ts @@ -0,0 +1,62 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery"; +import { listFilesByArtist, type ListedFileRecord } from "@/lib/files/listFilesByArtist"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Handles GET /api/files requests. + * + * @param request - The incoming request. + * @returns A NextResponse with files or an error. + */ +export async function getFilesHandler(request: NextRequest): Promise { + try { + const validatedQuery = await validateGetFilesQuery(request); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + const files = await listFilesByArtist( + validatedQuery.artist_account_id, + validatedQuery.path, + validatedQuery.recursive, + ); + + const ownerIds = Array.from(new Set(files.map(file => file.owner_account_id).filter(Boolean))); + const ownerEmailRows = ownerIds.length + ? await selectAccountEmails({ accountIds: ownerIds }) + : []; + + const ownerEmails = new Map(); + for (const row of ownerEmailRows) { + if (!row.account_id || ownerEmails.has(row.account_id)) continue; + ownerEmails.set(row.account_id, row.email ?? null); + } + + const enrichedFiles: ListedFileRecord[] = files.map(file => ({ + ...file, + owner_email: ownerEmails.get(file.owner_account_id) ?? null, + })); + + return NextResponse.json( + { files: enrichedFiles }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/files/listFilesByArtist.ts b/lib/files/listFilesByArtist.ts new file mode 100644 index 00000000..a6c27888 --- /dev/null +++ b/lib/files/listFilesByArtist.ts @@ -0,0 +1,42 @@ +import type { Tables } from "@/types/database.types"; +import { filterFilesByPath } from "@/lib/files/filterFilesByPath"; +import { getFilesByArtistId } from "@/lib/supabase/files/getFilesByArtistId"; + +type FileRecord = Tables<"files">; + +export interface ListedFileRecord extends FileRecord { + owner_email: string | null; +} + +/** + * Lists files for an artist, optionally filtered by path. + * + * @param artistAccountId - Artist account whose files should be listed. + * @param path - Optional relative directory path. + * @param recursive - Whether to include descendant files recursively. + * @returns Matching file rows. + */ +export async function listFilesByArtist( + artistAccountId: string, + path?: string, + recursive: boolean = false, +): Promise { + const allFiles = await getFilesByArtistId(artistAccountId); + + if (recursive) { + if (path) { + const pathPrefix = path.endsWith("/") ? path : `${path}/`; + return allFiles.filter(file => { + const match = file.storage_key.match(/^files\/[^\/]+\/[^\/]+\/(.+)$/); + if (!match) return false; + if (!match[1].startsWith(pathPrefix)) return false; + + return match[1] !== pathPrefix; + }); + } + + return allFiles; + } + + return filterFilesByPath(allFiles, path); +} diff --git a/lib/files/validateGetFilesQuery.ts b/lib/files/validateGetFilesQuery.ts new file mode 100644 index 00000000..900480b0 --- /dev/null +++ b/lib/files/validateGetFilesQuery.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const getFilesQuerySchema = z.object({ + artist_account_id: z.string().uuid("artist_account_id must be a valid UUID"), + path: z.string().optional(), + recursive: z.coerce.boolean().optional().default(false), +}); + +export type ValidatedGetFilesQuery = z.infer & { + requesterAccountId: string; +}; + +/** + * Validates GET /api/files query parameters and artist access. + * + * @param request - The incoming request. + * @returns A validation error response or the validated query. + */ +export async function validateGetFilesQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { searchParams } = new URL(request.url); + const validationResult = getFilesQuerySchema.safeParse( + Object.fromEntries(searchParams.entries()), + ); + + if (!validationResult.success) { + const firstError = validationResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const hasArtistAccess = await checkAccountArtistAccess( + authResult.accountId, + validationResult.data.artist_account_id, + ); + + if (!hasArtistAccess) { + return NextResponse.json( + { + status: "error", + error: "Access denied to specified artist_account_id", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + ...validationResult.data, + requesterAccountId: authResult.accountId, + }; +} diff --git a/lib/supabase/files/getFilesByArtistId.ts b/lib/supabase/files/getFilesByArtistId.ts new file mode 100644 index 00000000..c5cf9dc6 --- /dev/null +++ b/lib/supabase/files/getFilesByArtistId.ts @@ -0,0 +1,24 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +type FileRecord = Tables<"files">; + +/** + * Fetches all file rows for an artist, newest first. + * + * @param artistAccountId - Artist account whose files should be fetched. + * @returns File rows ordered by creation time descending. + */ +export async function getFilesByArtistId(artistAccountId: string): Promise { + const { data, error } = await supabase + .from("files") + .select("*") + .eq("artist_account_id", artistAccountId) + .order("created_at", { ascending: false }); + + if (error) { + throw new Error(`Failed to get files by artist: ${error.message}`); + } + + return data || []; +} From 5b5c57e6479a0950ed4dea624dd2ce3ab88c0cc9 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 00:07:13 +0530 Subject: [PATCH 2/7] fix: handle false recursive file queries --- .../__tests__/validateGetFilesQuery.test.ts | 21 +++++++++++++++++++ lib/files/constants.ts | 1 + lib/files/filterFilesByPath.ts | 3 ++- lib/files/getFilesHandler.ts | 9 ++++++-- lib/files/listFilesByArtist.ts | 7 ++----- lib/files/validateGetFilesQuery.ts | 3 ++- 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 lib/files/constants.ts diff --git a/lib/files/__tests__/validateGetFilesQuery.test.ts b/lib/files/__tests__/validateGetFilesQuery.test.ts index 0c3d9f0d..bcb33594 100644 --- a/lib/files/__tests__/validateGetFilesQuery.test.ts +++ b/lib/files/__tests__/validateGetFilesQuery.test.ts @@ -120,6 +120,27 @@ describe("validateGetFilesQuery", () => { }); }); + it("parses recursive=false as false", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&recursive=false", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + requesterAccountId: "account-123", + }); + }); + it("returns validated params for shared-org artist access", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "account-456", diff --git a/lib/files/constants.ts b/lib/files/constants.ts new file mode 100644 index 00000000..62cadc43 --- /dev/null +++ b/lib/files/constants.ts @@ -0,0 +1 @@ +export const STORAGE_KEY_RELATIVE_PATH_REGEX = /^files\/[^/]+\/[^/]+\/(.+)$/; diff --git a/lib/files/filterFilesByPath.ts b/lib/files/filterFilesByPath.ts index d36d4eb4..104483f3 100644 --- a/lib/files/filterFilesByPath.ts +++ b/lib/files/filterFilesByPath.ts @@ -1,4 +1,5 @@ import type { Tables } from "@/types/database.types"; +import { STORAGE_KEY_RELATIVE_PATH_REGEX } from "@/lib/files/constants"; type FileRecord = Tables<"files">; @@ -11,7 +12,7 @@ type FileRecord = Tables<"files">; */ export function filterFilesByPath(files: FileRecord[], path?: string): FileRecord[] { return files.filter(file => { - const match = file.storage_key.match(/^files\/[^\/]+\/[^\/]+\/(.+)$/); + const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); if (!match) return false; const relativePath = match[1]; diff --git a/lib/files/getFilesHandler.ts b/lib/files/getFilesHandler.ts index e8ce2ad8..ccfb8523 100644 --- a/lib/files/getFilesHandler.ts +++ b/lib/files/getFilesHandler.ts @@ -1,10 +1,15 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import type { Tables } from "@/types/database.types"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery"; -import { listFilesByArtist, type ListedFileRecord } from "@/lib/files/listFilesByArtist"; +import { listFilesByArtist } from "@/lib/files/listFilesByArtist"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +type ListedFileRecord = Tables<"files"> & { + owner_email: string | null; +}; + /** * Handles GET /api/files requests. * @@ -24,7 +29,7 @@ export async function getFilesHandler(request: NextRequest): Promise file.owner_account_id).filter(Boolean))); + const ownerIds = Array.from(new Set(files.map(file => file.owner_account_id))); const ownerEmailRows = ownerIds.length ? await selectAccountEmails({ accountIds: ownerIds }) : []; diff --git a/lib/files/listFilesByArtist.ts b/lib/files/listFilesByArtist.ts index a6c27888..69606d9e 100644 --- a/lib/files/listFilesByArtist.ts +++ b/lib/files/listFilesByArtist.ts @@ -1,13 +1,10 @@ import type { Tables } from "@/types/database.types"; +import { STORAGE_KEY_RELATIVE_PATH_REGEX } from "@/lib/files/constants"; import { filterFilesByPath } from "@/lib/files/filterFilesByPath"; import { getFilesByArtistId } from "@/lib/supabase/files/getFilesByArtistId"; type FileRecord = Tables<"files">; -export interface ListedFileRecord extends FileRecord { - owner_email: string | null; -} - /** * Lists files for an artist, optionally filtered by path. * @@ -27,7 +24,7 @@ export async function listFilesByArtist( if (path) { const pathPrefix = path.endsWith("/") ? path : `${path}/`; return allFiles.filter(file => { - const match = file.storage_key.match(/^files\/[^\/]+\/[^\/]+\/(.+)$/); + const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); if (!match) return false; if (!match[1].startsWith(pathPrefix)) return false; diff --git a/lib/files/validateGetFilesQuery.ts b/lib/files/validateGetFilesQuery.ts index 900480b0..34717a51 100644 --- a/lib/files/validateGetFilesQuery.ts +++ b/lib/files/validateGetFilesQuery.ts @@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { booleanFromString } from "@/lib/content/booleanFromString"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; export const getFilesQuerySchema = z.object({ artist_account_id: z.string().uuid("artist_account_id must be a valid UUID"), path: z.string().optional(), - recursive: z.coerce.boolean().optional().default(false), + recursive: booleanFromString.optional().default(false), }); export type ValidatedGetFilesQuery = z.infer & { From 7adebb6597692f9b80d5f89edcf5940ad34f6167 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 00:33:02 +0530 Subject: [PATCH 3/7] refactor: extract file enrichment --- lib/files/__tests__/enrichFiles.test.ts | 87 +++++++++++++++++++ lib/files/__tests__/getFilesHandler.test.ts | 72 ++------------- lib/files/__tests__/listFilesByArtist.test.ts | 2 +- lib/files/enrichFiles.ts | 30 +++++++ lib/files/getFilesHandler.ts | 24 +---- lib/files/listFilesByArtist.ts | 4 +- 6 files changed, 129 insertions(+), 90 deletions(-) create mode 100644 lib/files/__tests__/enrichFiles.test.ts create mode 100644 lib/files/enrichFiles.ts diff --git a/lib/files/__tests__/enrichFiles.test.ts b/lib/files/__tests__/enrichFiles.test.ts new file mode 100644 index 00000000..649b4420 --- /dev/null +++ b/lib/files/__tests__/enrichFiles.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { enrichFiles } from "../enrichFiles"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +const baseFile = { + id: "550e8400-e29b-41d4-a716-446655440010", + owner_account_id: "550e8400-e29b-41d4-a716-446655440100", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + file_name: "report.md", + storage_key: + "files/550e8400-e29b-41d4-a716-446655440100/550e8400-e29b-41d4-a716-446655440000/report.md", + mime_type: "text/markdown", + is_directory: false, + size_bytes: 123, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", +}; + +describe("enrichFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns files with owner_email when one exists", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-1", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "owner@example.com", + updated_at: "2026-04-09T00:00:00.000Z", + }, + ]); + + const result = await enrichFiles([baseFile]); + + expect(selectAccountEmails).toHaveBeenCalledWith({ + accountIds: ["550e8400-e29b-41d4-a716-446655440100"], + }); + expect(result).toEqual([{ ...baseFile, owner_email: "owner@example.com" }]); + }); + + it("returns owner_email as null when no email row exists", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await enrichFiles([baseFile]); + + expect(result).toEqual([{ ...baseFile, owner_email: null }]); + }); + + it("deduplicates owner account IDs before fetching emails", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-1", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "owner@example.com", + updated_at: "2026-04-09T00:00:00.000Z", + }, + ]); + + await enrichFiles([ + baseFile, + { + ...baseFile, + id: "550e8400-e29b-41d4-a716-446655440011", + file_name: "second.md", + }, + ]); + + expect(selectAccountEmails).toHaveBeenCalledTimes(1); + expect(selectAccountEmails).toHaveBeenCalledWith({ + accountIds: ["550e8400-e29b-41d4-a716-446655440100"], + }); + }); + + it("returns an empty array without fetching emails when no files match", async () => { + const result = await enrichFiles([]); + + expect(selectAccountEmails).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/lib/files/__tests__/getFilesHandler.test.ts b/lib/files/__tests__/getFilesHandler.test.ts index b2e9adb1..25ba4e31 100644 --- a/lib/files/__tests__/getFilesHandler.test.ts +++ b/lib/files/__tests__/getFilesHandler.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextResponse, type NextRequest } from "next/server"; import { getFilesHandler } from "../getFilesHandler"; +import { enrichFiles } from "../enrichFiles"; import { validateGetFilesQuery } from "../validateGetFilesQuery"; import { listFilesByArtist } from "../listFilesByArtist"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -17,8 +17,8 @@ vi.mock("../listFilesByArtist", () => ({ listFilesByArtist: vi.fn(), })); -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: vi.fn(), +vi.mock("../enrichFiles", () => ({ + enrichFiles: vi.fn(), })); /** @@ -64,21 +64,14 @@ describe("getFilesHandler", () => { expect(result.status).toBe(401); }); - it("returns files with owner_email when one exists", async () => { + it("returns enriched files", async () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, requesterAccountId: "acc", }); vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); - vi.mocked(selectAccountEmails).mockResolvedValue([ - { - id: "email-1", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "owner@example.com", - updated_at: "2026-04-09T00:00:00.000Z", - }, - ]); + vi.mocked(enrichFiles).mockResolvedValue([{ ...baseFile, owner_email: "owner@example.com" }]); const result = await getFilesHandler(createRequest()); const body = await result.json(); @@ -88,63 +81,13 @@ describe("getFilesHandler", () => { undefined, false, ); - expect(selectAccountEmails).toHaveBeenCalledWith({ - accountIds: ["550e8400-e29b-41d4-a716-446655440100"], - }); + expect(enrichFiles).toHaveBeenCalledWith([baseFile]); expect(result.status).toBe(200); expect(body).toEqual({ files: [{ ...baseFile, owner_email: "owner@example.com" }], }); }); - it("returns owner_email as null when no email row exists", async () => { - vi.mocked(validateGetFilesQuery).mockResolvedValue({ - artist_account_id: "550e8400-e29b-41d4-a716-446655440000", - recursive: false, - requesterAccountId: "acc", - }); - vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); - vi.mocked(selectAccountEmails).mockResolvedValue([]); - - const result = await getFilesHandler(createRequest()); - const body = await result.json(); - - expect(body).toEqual({ - files: [{ ...baseFile, owner_email: null }], - }); - }); - - it("deduplicates owner account IDs before fetching emails", async () => { - vi.mocked(validateGetFilesQuery).mockResolvedValue({ - artist_account_id: "550e8400-e29b-41d4-a716-446655440000", - recursive: true, - requesterAccountId: "acc", - }); - vi.mocked(listFilesByArtist).mockResolvedValue([ - baseFile, - { - ...baseFile, - id: "550e8400-e29b-41d4-a716-446655440011", - file_name: "second.md", - }, - ]); - vi.mocked(selectAccountEmails).mockResolvedValue([ - { - id: "email-1", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "owner@example.com", - updated_at: "2026-04-09T00:00:00.000Z", - }, - ]); - - await getFilesHandler(createRequest()); - - expect(selectAccountEmails).toHaveBeenCalledTimes(1); - expect(selectAccountEmails).toHaveBeenCalledWith({ - accountIds: ["550e8400-e29b-41d4-a716-446655440100"], - }); - }); - it("returns an empty files array without fetching emails when no files match", async () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", @@ -152,11 +95,12 @@ describe("getFilesHandler", () => { requesterAccountId: "acc", }); vi.mocked(listFilesByArtist).mockResolvedValue([]); + vi.mocked(enrichFiles).mockResolvedValue([]); const result = await getFilesHandler(createRequest()); const body = await result.json(); - expect(selectAccountEmails).not.toHaveBeenCalled(); + expect(enrichFiles).toHaveBeenCalledWith([]); expect(body).toEqual({ files: [] }); }); diff --git a/lib/files/__tests__/listFilesByArtist.test.ts b/lib/files/__tests__/listFilesByArtist.test.ts index 7930a427..71c882f7 100644 --- a/lib/files/__tests__/listFilesByArtist.test.ts +++ b/lib/files/__tests__/listFilesByArtist.test.ts @@ -86,6 +86,6 @@ describe("listFilesByArtist", () => { it("returns all descendants when recursive is true", async () => { const result = await listFilesByArtist("artist-1", "reports", true); - expect(result.map(file => file.id)).toEqual(["3", "4"]); + expect(result.map(file => file.id)).toEqual(["2", "3", "4"]); }); }); diff --git a/lib/files/enrichFiles.ts b/lib/files/enrichFiles.ts new file mode 100644 index 00000000..a7f8c7ab --- /dev/null +++ b/lib/files/enrichFiles.ts @@ -0,0 +1,30 @@ +import type { Tables } from "@/types/database.types"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +type FileRecord = Tables<"files">; + +export type ListedFileRecord = FileRecord & { + owner_email: string | null; +}; + +/** + * Enriches file rows with the owner's email address when available. + * + * @param files - File rows from the database. + * @returns The file rows with owner_email attached. + */ +export async function enrichFiles(files: FileRecord[]): Promise { + const ownerIds = Array.from(new Set(files.map(file => file.owner_account_id))); + const ownerEmailRows = ownerIds.length ? await selectAccountEmails({ accountIds: ownerIds }) : []; + + const ownerEmails = new Map(); + for (const row of ownerEmailRows) { + if (!row.account_id || ownerEmails.has(row.account_id)) continue; + ownerEmails.set(row.account_id, row.email ?? null); + } + + return files.map(file => ({ + ...file, + owner_email: ownerEmails.get(file.owner_account_id) ?? null, + })); +} diff --git a/lib/files/getFilesHandler.ts b/lib/files/getFilesHandler.ts index ccfb8523..ceac17f9 100644 --- a/lib/files/getFilesHandler.ts +++ b/lib/files/getFilesHandler.ts @@ -1,14 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import type { Tables } from "@/types/database.types"; +import { enrichFiles } from "@/lib/files/enrichFiles"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery"; import { listFilesByArtist } from "@/lib/files/listFilesByArtist"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -type ListedFileRecord = Tables<"files"> & { - owner_email: string | null; -}; /** * Handles GET /api/files requests. @@ -28,22 +23,7 @@ export async function getFilesHandler(request: NextRequest): Promise file.owner_account_id))); - const ownerEmailRows = ownerIds.length - ? await selectAccountEmails({ accountIds: ownerIds }) - : []; - - const ownerEmails = new Map(); - for (const row of ownerEmailRows) { - if (!row.account_id || ownerEmails.has(row.account_id)) continue; - ownerEmails.set(row.account_id, row.email ?? null); - } - - const enrichedFiles: ListedFileRecord[] = files.map(file => ({ - ...file, - owner_email: ownerEmails.get(file.owner_account_id) ?? null, - })); + const enrichedFiles = await enrichFiles(files); return NextResponse.json( { files: enrichedFiles }, diff --git a/lib/files/listFilesByArtist.ts b/lib/files/listFilesByArtist.ts index 69606d9e..6fa856e8 100644 --- a/lib/files/listFilesByArtist.ts +++ b/lib/files/listFilesByArtist.ts @@ -26,9 +26,7 @@ export async function listFilesByArtist( return allFiles.filter(file => { const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); if (!match) return false; - if (!match[1].startsWith(pathPrefix)) return false; - - return match[1] !== pathPrefix; + return match[1].startsWith(pathPrefix); }); } From 3ff7307d43293063ae8de893c655825706a63d7b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 01:45:37 +0530 Subject: [PATCH 4/7] refactor: simplify file query validation --- lib/files/__tests__/validateGetFilesQuery.test.ts | 3 --- lib/files/validateGetFilesQuery.ts | 9 ++------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/files/__tests__/validateGetFilesQuery.test.ts b/lib/files/__tests__/validateGetFilesQuery.test.ts index bcb33594..ea5f4621 100644 --- a/lib/files/__tests__/validateGetFilesQuery.test.ts +++ b/lib/files/__tests__/validateGetFilesQuery.test.ts @@ -116,7 +116,6 @@ describe("validateGetFilesQuery", () => { artist_account_id: "550e8400-e29b-41d4-a716-446655440000", path: "reports", recursive: true, - requesterAccountId: "account-123", }); }); @@ -137,7 +136,6 @@ describe("validateGetFilesQuery", () => { expect(result).toEqual({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, - requesterAccountId: "account-123", }); }); @@ -158,7 +156,6 @@ describe("validateGetFilesQuery", () => { expect(result).toEqual({ artist_account_id: "550e8400-e29b-41d4-a716-446655440001", recursive: false, - requesterAccountId: "account-456", }); }); }); diff --git a/lib/files/validateGetFilesQuery.ts b/lib/files/validateGetFilesQuery.ts index 34717a51..be9360fb 100644 --- a/lib/files/validateGetFilesQuery.ts +++ b/lib/files/validateGetFilesQuery.ts @@ -11,9 +11,7 @@ export const getFilesQuerySchema = z.object({ recursive: booleanFromString.optional().default(false), }); -export type ValidatedGetFilesQuery = z.infer & { - requesterAccountId: string; -}; +export type ValidatedGetFilesQuery = z.infer; /** * Validates GET /api/files query parameters and artist access. @@ -67,8 +65,5 @@ export async function validateGetFilesQuery( ); } - return { - ...validationResult.data, - requesterAccountId: authResult.accountId, - }; + return validationResult.data; } From 0314ed84a65ad2ac1bcf9834a917594be2fb32d6 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 04:18:35 +0530 Subject: [PATCH 5/7] fix: align file listing edge cases --- lib/files/__tests__/enrichFiles.test.ts | 21 +++++++++++++++++++ lib/files/__tests__/listFilesByArtist.test.ts | 2 +- lib/files/enrichFiles.ts | 8 ++++++- lib/files/listFilesByArtist.ts | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/files/__tests__/enrichFiles.test.ts b/lib/files/__tests__/enrichFiles.test.ts index 649b4420..11a9afb3 100644 --- a/lib/files/__tests__/enrichFiles.test.ts +++ b/lib/files/__tests__/enrichFiles.test.ts @@ -78,6 +78,27 @@ describe("enrichFiles", () => { }); }); + it("uses the most recently updated email when an owner has multiple rows", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([ + { + id: "email-older", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "older@example.com", + updated_at: "2026-04-08T00:00:00.000Z", + }, + { + id: "email-newer", + account_id: "550e8400-e29b-41d4-a716-446655440100", + email: "newer@example.com", + updated_at: "2026-04-09T00:00:00.000Z", + }, + ]); + + const result = await enrichFiles([baseFile]); + + expect(result).toEqual([{ ...baseFile, owner_email: "newer@example.com" }]); + }); + it("returns an empty array without fetching emails when no files match", async () => { const result = await enrichFiles([]); diff --git a/lib/files/__tests__/listFilesByArtist.test.ts b/lib/files/__tests__/listFilesByArtist.test.ts index 71c882f7..7930a427 100644 --- a/lib/files/__tests__/listFilesByArtist.test.ts +++ b/lib/files/__tests__/listFilesByArtist.test.ts @@ -86,6 +86,6 @@ describe("listFilesByArtist", () => { it("returns all descendants when recursive is true", async () => { const result = await listFilesByArtist("artist-1", "reports", true); - expect(result.map(file => file.id)).toEqual(["2", "3", "4"]); + expect(result.map(file => file.id)).toEqual(["3", "4"]); }); }); diff --git a/lib/files/enrichFiles.ts b/lib/files/enrichFiles.ts index a7f8c7ab..4046756b 100644 --- a/lib/files/enrichFiles.ts +++ b/lib/files/enrichFiles.ts @@ -18,9 +18,15 @@ export async function enrichFiles(files: FileRecord[]): Promise(); + const ownerEmailUpdatedAts = new Map(); for (const row of ownerEmailRows) { - if (!row.account_id || ownerEmails.has(row.account_id)) continue; + if (!row.account_id) continue; + + const existingUpdatedAt = ownerEmailUpdatedAts.get(row.account_id); + if (existingUpdatedAt && row.updated_at <= existingUpdatedAt) continue; + ownerEmails.set(row.account_id, row.email ?? null); + ownerEmailUpdatedAts.set(row.account_id, row.updated_at); } return files.map(file => ({ diff --git a/lib/files/listFilesByArtist.ts b/lib/files/listFilesByArtist.ts index 6fa856e8..1afefb1d 100644 --- a/lib/files/listFilesByArtist.ts +++ b/lib/files/listFilesByArtist.ts @@ -26,7 +26,7 @@ export async function listFilesByArtist( return allFiles.filter(file => { const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); if (!match) return false; - return match[1].startsWith(pathPrefix); + return match[1] !== pathPrefix && match[1].startsWith(pathPrefix); }); } From 3bd2a489e3ce6f4eb4e5f0e9791822df3f2f642d Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 04:37:31 +0530 Subject: [PATCH 6/7] test: trim file list unit coverage --- lib/files/__tests__/enrichFiles.test.ts | 108 ------------------ lib/files/__tests__/getFilesHandler.test.ts | 3 - lib/files/__tests__/listFilesByArtist.test.ts | 91 --------------- 3 files changed, 202 deletions(-) delete mode 100644 lib/files/__tests__/enrichFiles.test.ts delete mode 100644 lib/files/__tests__/listFilesByArtist.test.ts diff --git a/lib/files/__tests__/enrichFiles.test.ts b/lib/files/__tests__/enrichFiles.test.ts deleted file mode 100644 index 11a9afb3..00000000 --- a/lib/files/__tests__/enrichFiles.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { enrichFiles } from "../enrichFiles"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: vi.fn(), -})); - -const baseFile = { - id: "550e8400-e29b-41d4-a716-446655440010", - owner_account_id: "550e8400-e29b-41d4-a716-446655440100", - artist_account_id: "550e8400-e29b-41d4-a716-446655440000", - file_name: "report.md", - storage_key: - "files/550e8400-e29b-41d4-a716-446655440100/550e8400-e29b-41d4-a716-446655440000/report.md", - mime_type: "text/markdown", - is_directory: false, - size_bytes: 123, - description: null, - tags: null, - created_at: "2026-04-09T00:00:00.000Z", - updated_at: "2026-04-09T00:00:00.000Z", -}; - -describe("enrichFiles", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns files with owner_email when one exists", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([ - { - id: "email-1", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "owner@example.com", - updated_at: "2026-04-09T00:00:00.000Z", - }, - ]); - - const result = await enrichFiles([baseFile]); - - expect(selectAccountEmails).toHaveBeenCalledWith({ - accountIds: ["550e8400-e29b-41d4-a716-446655440100"], - }); - expect(result).toEqual([{ ...baseFile, owner_email: "owner@example.com" }]); - }); - - it("returns owner_email as null when no email row exists", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([]); - - const result = await enrichFiles([baseFile]); - - expect(result).toEqual([{ ...baseFile, owner_email: null }]); - }); - - it("deduplicates owner account IDs before fetching emails", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([ - { - id: "email-1", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "owner@example.com", - updated_at: "2026-04-09T00:00:00.000Z", - }, - ]); - - await enrichFiles([ - baseFile, - { - ...baseFile, - id: "550e8400-e29b-41d4-a716-446655440011", - file_name: "second.md", - }, - ]); - - expect(selectAccountEmails).toHaveBeenCalledTimes(1); - expect(selectAccountEmails).toHaveBeenCalledWith({ - accountIds: ["550e8400-e29b-41d4-a716-446655440100"], - }); - }); - - it("uses the most recently updated email when an owner has multiple rows", async () => { - vi.mocked(selectAccountEmails).mockResolvedValue([ - { - id: "email-older", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "older@example.com", - updated_at: "2026-04-08T00:00:00.000Z", - }, - { - id: "email-newer", - account_id: "550e8400-e29b-41d4-a716-446655440100", - email: "newer@example.com", - updated_at: "2026-04-09T00:00:00.000Z", - }, - ]); - - const result = await enrichFiles([baseFile]); - - expect(result).toEqual([{ ...baseFile, owner_email: "newer@example.com" }]); - }); - - it("returns an empty array without fetching emails when no files match", async () => { - const result = await enrichFiles([]); - - expect(selectAccountEmails).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); -}); diff --git a/lib/files/__tests__/getFilesHandler.test.ts b/lib/files/__tests__/getFilesHandler.test.ts index 25ba4e31..f0886743 100644 --- a/lib/files/__tests__/getFilesHandler.test.ts +++ b/lib/files/__tests__/getFilesHandler.test.ts @@ -68,7 +68,6 @@ describe("getFilesHandler", () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, - requesterAccountId: "acc", }); vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); vi.mocked(enrichFiles).mockResolvedValue([{ ...baseFile, owner_email: "owner@example.com" }]); @@ -92,7 +91,6 @@ describe("getFilesHandler", () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, - requesterAccountId: "acc", }); vi.mocked(listFilesByArtist).mockResolvedValue([]); vi.mocked(enrichFiles).mockResolvedValue([]); @@ -108,7 +106,6 @@ describe("getFilesHandler", () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, - requesterAccountId: "acc", }); vi.mocked(listFilesByArtist).mockRejectedValue(new Error("db blew up")); diff --git a/lib/files/__tests__/listFilesByArtist.test.ts b/lib/files/__tests__/listFilesByArtist.test.ts deleted file mode 100644 index 7930a427..00000000 --- a/lib/files/__tests__/listFilesByArtist.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { listFilesByArtist } from "../listFilesByArtist"; -import { getFilesByArtistId } from "@/lib/supabase/files/getFilesByArtistId"; - -vi.mock("@/lib/supabase/files/getFilesByArtistId", () => ({ - getFilesByArtistId: vi.fn(), -})); - -const files = [ - { - id: "1", - owner_account_id: "owner-1", - artist_account_id: "artist-1", - file_name: "root.txt", - storage_key: "files/owner-1/artist-1/root.txt", - mime_type: "text/plain", - is_directory: false, - size_bytes: 10, - description: null, - tags: null, - created_at: "2026-04-09T00:00:00.000Z", - updated_at: "2026-04-09T00:00:00.000Z", - }, - { - id: "2", - owner_account_id: "owner-1", - artist_account_id: "artist-1", - file_name: "reports", - storage_key: "files/owner-1/artist-1/reports/", - mime_type: null, - is_directory: true, - size_bytes: null, - description: null, - tags: null, - created_at: "2026-04-09T00:00:00.000Z", - updated_at: "2026-04-09T00:00:00.000Z", - }, - { - id: "3", - owner_account_id: "owner-2", - artist_account_id: "artist-1", - file_name: "weekly.md", - storage_key: "files/owner-2/artist-1/reports/weekly.md", - mime_type: "text/markdown", - is_directory: false, - size_bytes: 20, - description: null, - tags: null, - created_at: "2026-04-09T00:00:00.000Z", - updated_at: "2026-04-09T00:00:00.000Z", - }, - { - id: "4", - owner_account_id: "owner-2", - artist_account_id: "artist-1", - file_name: "april.md", - storage_key: "files/owner-2/artist-1/reports/monthly/april.md", - mime_type: "text/markdown", - is_directory: false, - size_bytes: 20, - description: null, - tags: null, - created_at: "2026-04-09T00:00:00.000Z", - updated_at: "2026-04-09T00:00:00.000Z", - }, -]; - -describe("listFilesByArtist", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getFilesByArtistId).mockResolvedValue(files); - }); - - it("returns only root-level entries when no path is provided", async () => { - const result = await listFilesByArtist("artist-1"); - - expect(result.map(file => file.id)).toEqual(["1", "2"]); - }); - - it("returns only immediate children for a nested path", async () => { - const result = await listFilesByArtist("artist-1", "reports"); - - expect(result.map(file => file.id)).toEqual(["3"]); - }); - - it("returns all descendants when recursive is true", async () => { - const result = await listFilesByArtist("artist-1", "reports", true); - - expect(result.map(file => file.id)).toEqual(["3", "4"]); - }); -}); From 00a084ab28c0b65e62471f1558a940c6ef89c628 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 10 Apr 2026 05:00:33 +0530 Subject: [PATCH 7/7] refactor: remove file owner email enrichment --- lib/files/__tests__/getFilesHandler.test.ts | 13 ++------ lib/files/enrichFiles.ts | 36 --------------------- lib/files/getFilesHandler.ts | 4 +-- 3 files changed, 3 insertions(+), 50 deletions(-) delete mode 100644 lib/files/enrichFiles.ts diff --git a/lib/files/__tests__/getFilesHandler.test.ts b/lib/files/__tests__/getFilesHandler.test.ts index f0886743..6165bc4a 100644 --- a/lib/files/__tests__/getFilesHandler.test.ts +++ b/lib/files/__tests__/getFilesHandler.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextResponse, type NextRequest } from "next/server"; import { getFilesHandler } from "../getFilesHandler"; -import { enrichFiles } from "../enrichFiles"; import { validateGetFilesQuery } from "../validateGetFilesQuery"; import { listFilesByArtist } from "../listFilesByArtist"; @@ -17,10 +16,6 @@ vi.mock("../listFilesByArtist", () => ({ listFilesByArtist: vi.fn(), })); -vi.mock("../enrichFiles", () => ({ - enrichFiles: vi.fn(), -})); - /** * Creates a mock request for GET /api/files tests. * @@ -70,7 +65,6 @@ describe("getFilesHandler", () => { recursive: false, }); vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); - vi.mocked(enrichFiles).mockResolvedValue([{ ...baseFile, owner_email: "owner@example.com" }]); const result = await getFilesHandler(createRequest()); const body = await result.json(); @@ -80,25 +74,22 @@ describe("getFilesHandler", () => { undefined, false, ); - expect(enrichFiles).toHaveBeenCalledWith([baseFile]); expect(result.status).toBe(200); expect(body).toEqual({ - files: [{ ...baseFile, owner_email: "owner@example.com" }], + files: [baseFile], }); }); - it("returns an empty files array without fetching emails when no files match", async () => { + it("returns an empty files array when no files match", async () => { vi.mocked(validateGetFilesQuery).mockResolvedValue({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000", recursive: false, }); vi.mocked(listFilesByArtist).mockResolvedValue([]); - vi.mocked(enrichFiles).mockResolvedValue([]); const result = await getFilesHandler(createRequest()); const body = await result.json(); - expect(enrichFiles).toHaveBeenCalledWith([]); expect(body).toEqual({ files: [] }); }); diff --git a/lib/files/enrichFiles.ts b/lib/files/enrichFiles.ts deleted file mode 100644 index 4046756b..00000000 --- a/lib/files/enrichFiles.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Tables } from "@/types/database.types"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -type FileRecord = Tables<"files">; - -export type ListedFileRecord = FileRecord & { - owner_email: string | null; -}; - -/** - * Enriches file rows with the owner's email address when available. - * - * @param files - File rows from the database. - * @returns The file rows with owner_email attached. - */ -export async function enrichFiles(files: FileRecord[]): Promise { - const ownerIds = Array.from(new Set(files.map(file => file.owner_account_id))); - const ownerEmailRows = ownerIds.length ? await selectAccountEmails({ accountIds: ownerIds }) : []; - - const ownerEmails = new Map(); - const ownerEmailUpdatedAts = new Map(); - for (const row of ownerEmailRows) { - if (!row.account_id) continue; - - const existingUpdatedAt = ownerEmailUpdatedAts.get(row.account_id); - if (existingUpdatedAt && row.updated_at <= existingUpdatedAt) continue; - - ownerEmails.set(row.account_id, row.email ?? null); - ownerEmailUpdatedAts.set(row.account_id, row.updated_at); - } - - return files.map(file => ({ - ...file, - owner_email: ownerEmails.get(file.owner_account_id) ?? null, - })); -} diff --git a/lib/files/getFilesHandler.ts b/lib/files/getFilesHandler.ts index ceac17f9..80881a11 100644 --- a/lib/files/getFilesHandler.ts +++ b/lib/files/getFilesHandler.ts @@ -1,6 +1,5 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { enrichFiles } from "@/lib/files/enrichFiles"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery"; import { listFilesByArtist } from "@/lib/files/listFilesByArtist"; @@ -23,10 +22,9 @@ export async function getFilesHandler(request: NextRequest): Promise