Skip to content

feat: user asset storage library#90

Open
remcostoeten wants to merge 2 commits intodaddyfrom
feature/asset-library
Open

feat: user asset storage library#90
remcostoeten wants to merge 2 commits intodaddyfrom
feature/asset-library

Conversation

@remcostoeten
Copy link
Owner

@remcostoeten remcostoeten commented Feb 5, 2026

Summary by Sourcery

Introduce a full-featured user asset management system and integrate it into the web app’s media workflows.

New Features:

  • Add an AssetLibrary UI component for browsing, uploading, renaming, and deleting user assets with grid/list views, pagination, and search.
  • Expose new assets API endpoints for listing, creating, updating, deleting, and refreshing asset records, including per-user filtering and auth checks.
  • Sync uploads from multiple storage backends (UploadThing, Tauri, local FS) into the shared assets database and extend the files schema with metadata like originalName, storageProvider, and visibility flags.
  • Add a media library slash-command item in the editor to open the asset picker and select existing uploads.

Enhancements:

  • Refine database and app scripts to consistently load environment variables via .env.local and simplify Drizzle configuration.
  • Compact Vercel project configuration and add a migration to ensure notes table columns exist in the database.
  • Replace the legacy MediaPicker implementation with the new AssetLibrary-backed experience.

Build:

  • Update package scripts across app and db packages to run Drizzle and Next processes with .env.local loaded by Bun.

Documentation:

  • Add internal prompt and design documents describing planned implementations for assets, onboarding, search, and import/export features.

Tests:

  • Add API tests covering assets CRUD and update flows against the new assets endpoints.

remcostoeten and others added 2 commits February 4, 2026 16:37
… Figma imports and API interactions, add a seed script and callback analyzer, and update the backup prompt.
@vercel
Copy link
Contributor

vercel bot commented Feb 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skriuw Error Error Feb 5, 2026 2:02am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

Warning

Rate limit exceeded

@remcostoeten has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 19 minutes and 14 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/asset-library

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 5, 2026

Reviewer's Guide

Implements a full user-facing asset library backed by the existing files table, including new API routes, React-query powered UI, DB schema extensions, and upload adapter integration, plus some env/script tweaks and tests around the new assets API.

Sequence diagram for Tauri/local asset upload and library refresh

sequenceDiagram
    actor User
    participant Editor
    participant AssetLibrary
    participant UseUpload as useUpload_hook
    participant UploadAdapter as upload_adapter
    participant SyncToDb as syncToDatabase
    participant AssetsPost as POST_api_assets
    participant ApiAuth as api_auth_requireMutation
    participant DB as Database
    participant ReactQuery as ReactQuery_client

    User->>Editor: choose file to upload
    Editor->>AssetLibrary: render with onSelect
    User->>AssetLibrary: click Upload and pick files
    AssetLibrary->>UseUpload: startUpload(files)
    UseUpload->>UploadAdapter: uploadWithTauriFs(file)
    UploadAdapter->>UploadAdapter: writeFile_to_local_fs
    UploadAdapter->>SyncToDb: syncToDatabase(result, tauri, file)
    SyncToDb->>AssetsPost: POST /api/assets {url, name, originalName, size, type, storageProvider}
    AssetsPost->>ApiAuth: requireMutation()
    ApiAuth-->>AssetsPost: userId
    AssetsPost->>DB: insert into files
    DB-->>AssetsPost: new row
    AssetsPost-->>SyncToDb: 201 Created
    SyncToDb-->>UploadAdapter: void
    UploadAdapter-->>UseUpload: UploadResult(url, name)
    UseUpload-->>AssetLibrary: onUploadComplete callback
    AssetLibrary->>ReactQuery: invalidateQueries(assets)
    ReactQuery->>AssetLibrary: refetch getAssets
    AssetLibrary-->>User: updated asset list with new item
Loading

ER diagram for updated files table and user relation

erDiagram
    user ||--o{ files : owns

    user {
        text id PK
        text email
        text name
        bigint created_at
    }

    files {
        text id PK
        text user_id FK
        text url
        text name
        text original_name
        integer size
        text type
        text storage_provider
        boolean is_public
        bigint created_at
    }
Loading

Class diagram for asset library UI and server API

classDiagram
    class AssetLibrary {
        +string view
        +number page
        +string search
        +AssetSort sort
        +string editingId
        +string editName
        +constructor AssetLibrary(onSelect, className)
        +handleRename(id, currentName) void
        +saveRename() Promise~void~
        +handleFileUpload(event) Promise~void~
        +copyToClipboard(url) void
    }

    class AssetMenu {
        +constructor AssetMenu(item, onRename, onDelete, onCopy)
    }

    class GetAssetsParams {
        +number page
        +number limit
        +string search
        +AssetSort sort
        +AssetOrder order
    }

    class AssetsResponse {
        +File[] items
        +number total
        +number page
        +number limit
        +number totalPages
    }

    class getAssets {
        +getAssets(page, limit, search, sort, order) Promise~AssetsResponse~
    }

    class updateAsset {
        +updateAsset(id, name, isPublic) Promise~void~
    }

    class destroyFile {
        +destroyFile(id) Promise~void~
    }

    class File {
        +string id
        +string userId
        +string url
        +string name
        +string originalName
        +number size
        +string type
        +string storageProvider
        +boolean isPublic
        +number createdAt
    }

    class AssetsApiList {
        +GET(request) Promise~Response~
    }

    class AssetsApiMutate {
        +POST(request) Promise~Response~
        +DELETE(request) Promise~Response~
    }

    class AssetApiItem {
        +GET(request, params) Promise~Response~
        +PATCH(request, params) Promise~Response~
    }

    class AssetApiRedownload {
        +POST(request, params) Promise~Response~
    }

    class UploadAdapter {
        +syncToDatabase(result, storageProvider, file) Promise~void~
        +uploadWithUploadThing(file) Promise~UploadResult~
        +uploadWithTauriFs(file) Promise~UploadResult~
        +uploadWithLocalStorage(file) Promise~UploadResult~
    }

    class UploadResult {
        +string url
        +string name
    }

    class AssetSort {
        <<enumeration>>
        createdAt
        name
        size
    }

    class AssetOrder {
        <<enumeration>>
        asc
        desc
    }

    AssetLibrary --> getAssets : uses
    AssetLibrary --> updateAsset : renames_updates
    AssetLibrary --> destroyFile : deletes
    AssetLibrary --> AssetMenu : renders
    AssetLibrary --> File : displays

    getAssets --> File : returns
    AssetsApiList --> File : queries
    AssetsApiMutate --> File : inserts_deletes
    AssetApiItem --> File : reads_updates
    AssetApiRedownload --> File : reads

    UploadAdapter --> UploadResult : returns
    UploadAdapter --> AssetsApiMutate : calls_POST_api_assets
Loading

File-Level Changes

Change Details Files
Replace the old inline media picker with a reusable, paginated AssetLibrary component wired to the new assets APIs.
  • Remove local file-fetching and deletion logic from the media picker component and delegate selection to AssetLibrary.
  • Introduce AssetLibrary with grid/list views, search, pagination, upload, rename, copy-url, download and delete operations using React Query and new API clients.
  • Wire AssetLibrary to useUpload so newly uploaded files invalidate and refresh the asset list.
apps/web/features/media/components/media-picker.tsx
apps/web/features/media/components/AssetLibrary.tsx
apps/web/features/uploads/use-upload.ts
apps/web/features/media/api/queries/get-assets.ts
apps/web/features/media/api/mutations/update-asset.ts
Add REST-style assets API endpoints and supporting tests for listing, creating, updating, deleting, and refreshing assets.
  • Implement /api/assets GET/POST/DELETE handlers with auth, pagination, search, and ownership checks.
  • Add /api/assets/[id] GET/PATCH for per-asset read/update and /api/assets/redownload/[id] POST as a stub for refreshing signed URLs.
  • Create Bun tests that mock auth and DB to verify listing, deletion, and patching behaviors for the assets API.
apps/web/app/api/assets/route.ts
apps/web/app/api/assets/[id]/route.ts
apps/web/app/api/assets/redownload/[id]/route.ts
apps/web/__tests__/media/assets-api.test.ts
Extend the files schema and wire uploads (UploadThing, Tauri/local) to register assets consistently in the database.
  • Add originalName, storageProvider and isPublic columns and defaults to the files table, plus a migration for some missing notes columns.
  • Update the UploadThing router to populate the new file metadata fields when persisting uploads.
  • Add a syncToDatabase helper in the upload adapter and call it from the Tauri FS upload path so those assets are also stored in the files table, while documenting that base64 local uploads remain guest-only and unsynced.
packages/db/src/schema.ts
packages/db/migrations/0004_add_notes_missing_columns.sql
apps/web/app/api/uploadthing/core.ts
apps/web/features/uploads/upload-adapter.ts
Surface the media library from the editor via a slash command and tighten up various project/runtime configurations.
  • Add a custom slash menu item that dispatches a window event to open the media library from the editor.
  • Compact the Vercel project config JSON and adjust Bun/Next/Drizzle/db scripts to load .env.local explicitly.
  • Make minor script/formatting tweaks (seed-identity-guide shebang, JSON whitespace).
apps/web/features/editor/hooks/useCustomSlashMenu.ts
apps/web/app/api/uploadthing/core.ts
.vercel/project.json
package.json
packages/db/package.json
apps/web/package.json
apps/web/scripts/seed-identity-guide.ts
Check in several high-level feature design prompts related to assets, onboarding, search, and note customization.
  • Add markdown prompt documents that describe planned asset management, onboarding flow, command palette/search, and note experience customization features.
  • These docs are non-executable and serve as architectural guidance rather than app code changes.
asset-prompt.md
onboarding-prompt.md
search-prompt.md
prompt.md
backup-prompt.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 10 issues, and left some high level feedback:

  • The asset listing logic (both in apps/web/app/api/assets/route.ts and features/media/api/queries/get-assets.ts) derives total via countResult.length, but that query currently selects { count: files.id } which will return one row per file; consider switching to a proper aggregate (e.g. count(*)) and reading the numeric count instead of using the array length so pagination metadata is correct.
  • Server-only modules (features/media/api/queries/get-assets.ts and features/media/api/mutations/update-asset.ts, both marked 'use server' and using auth/requireMutation) are imported directly into the client AssetLibrary component and used inside React Query; this will either fail at build time or at runtime—consider exposing these as HTTP endpoints or client-safe wrappers instead of invoking server actions from the browser.
  • The shebang line in apps/web/scripts/seed-identity-guide.ts was accidentally changed to include leading whitespace and spaces inside #!/usr/bin/env bun, which will break execution; it should be restored to a valid, unindented shebang.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The asset listing logic (both in `apps/web/app/api/assets/route.ts` and `features/media/api/queries/get-assets.ts`) derives `total` via `countResult.length`, but that query currently selects `{ count: files.id }` which will return one row per file; consider switching to a proper aggregate (e.g. `count(*)`) and reading the numeric count instead of using the array length so pagination metadata is correct.
- Server-only modules (`features/media/api/queries/get-assets.ts` and `features/media/api/mutations/update-asset.ts`, both marked `'use server'` and using `auth`/`requireMutation`) are imported directly into the client `AssetLibrary` component and used inside React Query; this will either fail at build time or at runtime—consider exposing these as HTTP endpoints or client-safe wrappers instead of invoking server actions from the browser.
- The shebang line in `apps/web/scripts/seed-identity-guide.ts` was accidentally changed to include leading whitespace and spaces inside `#!/usr/bin/env bun`, which will break execution; it should be restored to a valid, unindented shebang.

## Individual Comments

### Comment 1
<location> `apps/web/features/media/components/AssetLibrary.tsx:5-14` </location>
<code_context>
+import { getAssets, type AssetSort, type AssetOrder } from '../api/queries/get-assets'
</code_context>

<issue_to_address>
**issue (bug_risk):** Client component is calling a `use server` function directly via React Query, which will not bundle correctly.

`getAssets` is a `'use server'` function, but `AssetLibrary` is a client component and is using it as a React Query `queryFn`. Server actions/server-only modules can’t be called directly from client components; this will likely fail to bundle or break at runtime (e.g. no `auth`/DB access on the client).

Instead, either:
- Call your `/api/assets` REST endpoint via `fetch` in the `queryFn`, or
- Load the data in a server component and pass the results into a client-only presentation component.
</issue_to_address>

### Comment 2
<location> `apps/web/app/api/assets/route.ts:29-34` </location>
<code_context>
+            : eq(files.userId, userId)
+
+        // Get total count
+        const countResult = await db
+            .select({ count: files.id })
+            .from(files)
+            .where(whereClause)
+
+        const total = countResult.length
+
+        // Get paginated results
</code_context>

<issue_to_address>
**issue (bug_risk):** Total count computation in the assets API is incorrect for paginated responses.

This uses `select({ count: files.id })` and then `countResult.length` as `total`, which will always be `1` for an aggregate `COUNT` query. Instead, select an aggregate (e.g. `count(*)`) and read the value from the single row (e.g. `countResult[0].count`), then compute `totalPages` from that value.
</issue_to_address>

### Comment 3
<location> `apps/web/features/media/components/media-picker.tsx:12-14` </location>
<code_context>
-                </div>
-            ))}
-        </div>
+        <AssetLibrary
+            onSelect={onSelect}
+            className={className}
+        />
     )
</code_context>

<issue_to_address>
**issue (bug_risk):** The `onSelect` callback types between `MediaPicker` and `AssetLibrary` are incompatible in TypeScript.

`MediaPicker` exposes `onSelect: (url: string) => void`, but `AssetLibrary` expects `onSelect?: (url: string, file: any) => void` and calls it as `onSelect?.(item.url, item)`. In TS, a function with fewer params is not assignable to one with more, so passing `onSelect` through will fail type-checking.

To fix this, make the second parameter optional in `AssetLibrary`:
```ts
type AssetLibraryProps = {
  onSelect?: (url: string, file?: any) => void
}
```
This preserves runtime behavior while making the types compatible.
</issue_to_address>

### Comment 4
<location> `apps/web/scripts/seed-identity-guide.ts:1` </location>
<code_context>
-#!/usr/bin/env bun
+		#!/usr/bin / env bun

 /**
</code_context>

<issue_to_address>
**issue (bug_risk):** The shebang line has extra whitespace and indentation, which will break execution as a CLI script.

The shebang was changed from `#!/usr/bin/env bun` to `		#!/usr/bin / env bun`, which adds whitespace inside `/usr/bin/env` and before the `#!`. It needs to be reverted to exactly:
```sh
#!/usr/bin/env bun
```
so the interpreter path is valid and the script stays executable as a CLI.
</issue_to_address>

### Comment 5
<location> `apps/web/app/api/assets/[id]/route.ts:5-8` </location>
<code_context>
+import { requireMutation } from '@/lib/api-auth'
+import { getDatabase, files, eq, and } from '@skriuw/db'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+// PATCH /api/assets/:id - Rename or toggle visibility
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+    try {
+        const auth = await requireMutation()
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Route handler `params` is incorrectly typed as a `Promise`, and then awaited unnecessarily.

`params` is passed as a plain object, so typing it as `Promise<{ id: string }>` and awaiting it is incorrect and misleading for both consumers and tooling.

You can correct this to:
```ts
type RouteParams = { params: { id: string } }

export async function PATCH(request: NextRequest, { params }: RouteParams) {
  const { id } = params
  // ...
}
```
Please update the `redownload` route similarly for consistency.

Suggested implementation:

```typescript
type RouteParams = { params: { id: string } }

```

```typescript
 // PATCH /api/assets/:id - Rename or toggle visibility
export async function PATCH(request: NextRequest, { params }: RouteParams) {
    const { id } = params

    try {
        const auth = await requireMutation()
        if (!auth.authenticated) return auth.response
        const { userId } = auth

        if (!id) {
            return NextResponse.json({ error: 'ID is required' }, { status: 400 })
        }

        const body = await request.json()
        const { name, isPublic } = body

```

You should apply the same pattern to the `redownload` route:
1. Ensure its `RouteParams` (or equivalent) type uses `params: { id: string }` (not a Promise).
2. Destructure `id` directly from `params` without `await`, ideally at the top of the route handler:
   `export async function redownload(request: NextRequest, { params }: RouteParams) { const { id } = params; ... }`.
</issue_to_address>

### Comment 6
<location> `apps/web/__tests__/media/assets-api.test.ts:2` </location>
<code_context>
+import { describe, it, expect, mock, beforeEach } from "bun:test";
+import { GET, POST, DELETE } from "@/app/api/assets/route";
+import { PATCH, GET as GET_ONE } from "@/app/api/assets/[id]/route";
+import { NextRequest } from "next/server";
</code_context>

<issue_to_address>
**suggestion (testing):** Add tests for POST /api/assets to cover asset creation and validation failures.

`POST` is imported but never exercised. Since the handler writes to the DB and enforces required fields and auth, please add tests that:
- Cover a successful creation for an authenticated user, including returned shape and defaulted fields.
- Assert `400` responses for missing required fields (e.g. `url`, `name`) and expected error payload.
- Assert unauthenticated/unauthorized responses when `requireMutation` reports `authenticated: false`.

Suggested implementation:

```typescript
 // Mock auth
const mockRequireMutation = mock();
const mockAllowReadAccess = mock();
const mockGetSession = mock();

mock.module("@/lib/api-auth", () => ({
    requireMutation: mockRequireMutation,
    allowReadAccess: mockAllowReadAccess,
    GUEST_USER_ID: "guest-id"
}));

describe("POST /api/assets", () => {
    const baseUrl = "http://localhost/api/assets";

    const makePostRequest = (body: unknown) =>
        new NextRequest(baseUrl, {
            method: "POST",
            body: JSON.stringify(body),
            headers: {
                "content-type": "application/json"
            }
        });

    beforeEach(() => {
        mockRequireMutation.mockReset();
        mockAllowReadAccess.mockReset();
        mockGetSession.mockReset();
    });

    it("creates a new asset for an authenticated user and returns the created asset with defaulted fields", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: true,
            userId: "user-123"
        });

        const payload = {
            name: "Example asset",
            url: "https://example.com/asset.png"
        };

        const req = makePostRequest(payload);
        const res = await POST(req);

        expect(res.status).toBe(201);

        const json = await res.json();

        // Basic shape: echo user-specified fields and include server-generated defaults
        expect(json).toEqual(
            expect.objectContaining({
                name: payload.name,
                url: payload.url
            })
        );
        // Defaulted / server-populated fields
        expect(json).toHaveProperty("id");
        expect(json.id).toBeTruthy();
        expect(json).toHaveProperty("createdAt");
        expect(json).toHaveProperty("updatedAt");
    });

    it("returns 400 and validation errors when required fields are missing", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: true,
            userId: "user-123"
        });

        // Missing both `name` and `url`
        const invalidPayload = {};

        const req = makePostRequest(invalidPayload);
        const res = await POST(req);

        expect(res.status).toBe(400);

        const json = await res.json();

        // Expect a structured validation error payload
        expect(json).toHaveProperty("error");
        expect(json.error).toEqual(
            expect.objectContaining({
                message: expect.any(String)
            })
        );

        // If the handler returns field-specific errors, assert they include `name` and `url`
        if ("fieldErrors" in json.error) {
            expect(json.error.fieldErrors).toEqual(
                expect.objectContaining({
                    name: expect.any(Array),
                    url: expect.any(Array)
                })
            );
        }
    });

    it("returns an unauthenticated/unauthorized response when requireMutation reports authenticated: false", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: false
        });

        const payload = {
            name: "Example asset",
            url: "https://example.com/asset.png"
        };

        const req = makePostRequest(payload);
        const res = await POST(req);

        // Use whatever status code the handler uses for unauthenticated requests (401 or 403 are typical)
        expect([401, 403]).toContain(res.status);

        const json = await res.json();

        expect(json).toHaveProperty("error");
        expect(json.error).toEqual(
            expect.objectContaining({
                message: expect.any(String)
            })
        );
    });

```

The above tests assume:
1. `requireMutation` is `async` and returns an object with at least `{ authenticated: boolean, userId?: string }`.
2. The `POST` handler:
   - Returns `201` on success with a JSON body that includes `id`, `name`, `url`, `createdAt`, and `updatedAt`.
   - Returns `400` on validation errors with a JSON payload containing an `error` object, optionally with `fieldErrors` keyed by field name.
   - Returns `401` or `403` when `authenticated: false`.

If your actual handler differs, adjust:
- The expected status codes in the assertions.
- The shape of the JSON payloads (`json.error`, `json.error.fieldErrors`, defaulted fields).
Also, if this test file already defines shared helpers for building `NextRequest` objects or common auth mocks, prefer reusing those instead of `makePostRequest` and/or the local `beforeEach` to stay consistent with the rest of the test suite.
</issue_to_address>

### Comment 7
<location> `apps/web/__tests__/media/assets-api.test.ts:22-31` </location>
<code_context>
+}));
+
+// Mock DB
+const mockDb = {
+    select: mock(() => mockDb),
+    from: mock(() => mockDb),
+    where: mock(() => mockDb),
+    limit: mock(() => mockDb),
+    offset: mock(() => mockDb),
+    orderBy: mock(() => mockDb),
+    insert: mock(() => mockDb),
+    values: mock(() => mockDb),
+    update: mock(() => mockDb),
+    set: mock(() => mockDb),
+    delete: mock(() => mockDb),
+    returning: mock(() => [])
+};
+
</code_context>

<issue_to_address>
**issue (testing):** The current DB mocking pattern is likely to break the query chaining used in the handlers.

`mockDb.select` is initially a chainable mock (`mock(() => mockDb)`), but in `"should query db for authenticated user"` you later override it with `mockReturnValueOnce([...])`. At that point `select` returns an array instead of a query builder, so `db.select().from().where()` will fail when it tries to access `from` on the array, and the test will throw instead of exercising the handler logic.

To fix this, keep `select` returning a chainable object and only mock the terminal step of the chain. For example, either:
- Keep `select` returning `mockDb` and add a terminal method (e.g. `execute`/`all`) whose return value you control per test; or
- Have `select` return an object with a custom `from`/`where` chain whose final method returns the desired array.

The key is that mocks must preserve the Drizzle query-builder chaining contract while still allowing per-test data control.
</issue_to_address>

### Comment 8
<location> `apps/web/__tests__/media/assets-api.test.ts:89` </location>
<code_context>
+        });
+    });
+
+    describe("DELETE /api/assets", () => {
+        it("should delete file if owned by user", async () => {
+            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
</code_context>

<issue_to_address>
**suggestion (testing):** Add negative-path tests for DELETE /api/assets (missing id, unauthorized, or not owned).

Since the handler has explicit branches for missing `id` (400), auth failure (unauthenticated from `requireMutation`), and file not found / not owned (404), please add tests to cover these cases so error handling and status codes are verified and protected against regressions.

Suggested implementation:

```typescript
    describe("DELETE /api/assets", () => {
        it("should delete file if owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            mockDb.select.mockReturnValue([{ id: "file-1", storageProvider: "local-fs" }]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(200);
            expect(data.success).toBe(true);
            expect(mockDb.delete).toHaveBeenCalled();
        });

        it("should return 400 if id is missing", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets");

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
            expect(mockDb.select).not.toHaveBeenCalled();
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 401 if user is not authenticated", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: false });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(401);
            expect(data.success).toBe(false);
            expect(mockDb.select).not.toHaveBeenCalled();
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 404 if file not found", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-unknown");

            mockDb.select.mockReturnValue([]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 404 if file is not owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            mockDb.select.mockReturnValue([
                { id: "file-1", storageProvider: "local-fs", userId: "other-user" },
            ]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

```

These tests assume:
- The DELETE handler returns a JSON body with `{ success: boolean }` for both success and error cases.
- The status codes are `400` for missing `id`, `401` for unauthenticated, and `404` for file not found or not owned.
- `mockDb.select` returns an array of file records, optionally including a `userId` field used by the handler to check ownership.

If the actual handler uses different response shapes, field names (e.g., `ownerId` instead of `userId`), or status codes, adjust the expectations and mocked record shape accordingly to match the real implementation.
</issue_to_address>

### Comment 9
<location> `apps/web/__tests__/media/assets-api.test.ts:3` </location>
<code_context>
+import { describe, it, expect, mock, beforeEach } from "bun:test";
+import { GET, POST, DELETE } from "@/app/api/assets/route";
+import { PATCH, GET as GET_ONE } from "@/app/api/assets/[id]/route";
+import { NextRequest } from "next/server";
+
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for GET /api/assets/[id] or removing the unused import.

The test file imports `GET as GET_ONE` from `@/app/api/assets/[id]/route`, but doesn’t exercise the single-asset GET handler. Please either add coverage for `GET /api/assets/:id` (at least a happy path and a failure case such as not owned/not found/unauthorized/missing id) or remove the unused import to keep the tests focused.

```suggestion
import { PATCH } from "@/app/api/assets/[id]/route";
```
</issue_to_address>

### Comment 10
<location> `apps/web/__tests__/media/assets-api.test.ts:104` </location>
<code_context>
+        });
+    });
+
+    describe("PATCH /api/assets/[id]", () => {
+        it("should update file name", async () => {
+            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
</code_context>

<issue_to_address>
**suggestion (testing):** Add tests for PATCH /api/assets/[id] validation and ownership checks.

The current test only covers the successful rename. Please also add tests for the other branches in this handler: missing `id` (400), no valid fields to update (both `name` and `isPublic` undefined → 400), file not found or not owned (404), and unauthorized access via `requireMutation`. This will better exercise the validation and ownership checks, especially for metadata-only updates and failure cases.

Suggested implementation:

```typescript
    describe("PATCH /api/assets/[id]", () => {
        it("should update file name", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            mockDb.select.mockReturnValue([
                { id: "file-1", userId: "user-1", storageProvider: "local-fs", name: "old.jpg", isPublic: false },
            ]);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(200);
            expect(data.success).toBe(true);
            expect(mockDb.update).toHaveBeenCalledWith(
                expect.objectContaining({ name: "new-name.jpg" })
            );
        });

        it("should return 400 when id param is missing", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            const res = await PATCH(req, { params: {} as any });
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
        });

        it("should return 400 when no valid fields are provided", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({}),
            } as any);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
        });

        it("should return 404 when asset is not found or not owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            mockDb.select.mockReturnValue([]); // not found / not owned

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
        });

        it("should return 401 when user is not authenticated", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: false, userId: null });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(401);
            expect(data.success).toBe(false);
        });

```

These tests assume:
1. `PATCH` is imported from the corresponding route file and accepts `(req, { params })`.
2. The handler returns `400` for missing `id` and when both `name` and `isPublic` are absent, `404` when the asset is not found / not owned, and `401` when `requireMutation` indicates an unauthenticated user.
3. The JSON response includes a `success` boolean.

If the actual handler uses different status codes or response shapes (e.g. 403 instead of 401, or no `success` flag), adjust the `expect(res.status)` and `expect(data.success)` assertions accordingly, and align the `mockDb.update` expectation with your real update call signature.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +5 to +14
import { getAssets, type AssetSort, type AssetOrder } from '../api/queries/get-assets'
import { updateAsset } from '../api/mutations/update-asset'
import { destroyFile } from '../api/mutations/destroy-file'
import {
Loader2, Search, Grid, List as ListIcon, MoreVertical,
File as FileIcon, Trash2, Edit2, Link as LinkIcon, Download, Eye
} from 'lucide-react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
Button, Input, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Client component is calling a use server function directly via React Query, which will not bundle correctly.

getAssets is a 'use server' function, but AssetLibrary is a client component and is using it as a React Query queryFn. Server actions/server-only modules can’t be called directly from client components; this will likely fail to bundle or break at runtime (e.g. no auth/DB access on the client).

Instead, either:

  • Call your /api/assets REST endpoint via fetch in the queryFn, or
  • Load the data in a server component and pass the results into a client-only presentation component.

Comment on lines +29 to +34
const countResult = await db
.select({ count: files.id })
.from(files)
.where(whereClause)

const total = countResult.length
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Total count computation in the assets API is incorrect for paginated responses.

This uses select({ count: files.id }) and then countResult.length as total, which will always be 1 for an aggregate COUNT query. Instead, select an aggregate (e.g. count(*)) and read the value from the single row (e.g. countResult[0].count), then compute totalPages from that value.

Comment on lines +12 to +14
<AssetLibrary
onSelect={onSelect}
className={className}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The onSelect callback types between MediaPicker and AssetLibrary are incompatible in TypeScript.

MediaPicker exposes onSelect: (url: string) => void, but AssetLibrary expects onSelect?: (url: string, file: any) => void and calls it as onSelect?.(item.url, item). In TS, a function with fewer params is not assignable to one with more, so passing onSelect through will fail type-checking.

To fix this, make the second parameter optional in AssetLibrary:

type AssetLibraryProps = {
  onSelect?: (url: string, file?: any) => void
}

This preserves runtime behavior while making the types compatible.

@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin / env bun
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The shebang line has extra whitespace and indentation, which will break execution as a CLI script.

The shebang was changed from #!/usr/bin/env bun to #!/usr/bin / env bun, which adds whitespace inside /usr/bin/env and before the #!. It needs to be reverted to exactly:

#!/usr/bin/env bun

so the interpreter path is valid and the script stays executable as a CLI.

Comment on lines +5 to +8
type RouteParams = { params: Promise<{ id: string }> }

// PATCH /api/assets/:id - Rename or toggle visibility
export async function PATCH(request: NextRequest, { params }: RouteParams) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Route handler params is incorrectly typed as a Promise, and then awaited unnecessarily.

params is passed as a plain object, so typing it as Promise<{ id: string }> and awaiting it is incorrect and misleading for both consumers and tooling.

You can correct this to:

type RouteParams = { params: { id: string } }

export async function PATCH(request: NextRequest, { params }: RouteParams) {
  const { id } = params
  // ...
}

Please update the redownload route similarly for consistency.

Suggested implementation:

type RouteParams = { params: { id: string } }
 // PATCH /api/assets/:id - Rename or toggle visibility
export async function PATCH(request: NextRequest, { params }: RouteParams) {
    const { id } = params

    try {
        const auth = await requireMutation()
        if (!auth.authenticated) return auth.response
        const { userId } = auth

        if (!id) {
            return NextResponse.json({ error: 'ID is required' }, { status: 400 })
        }

        const body = await request.json()
        const { name, isPublic } = body

You should apply the same pattern to the redownload route:

  1. Ensure its RouteParams (or equivalent) type uses params: { id: string } (not a Promise).
  2. Destructure id directly from params without await, ideally at the top of the route handler:
    export async function redownload(request: NextRequest, { params }: RouteParams) { const { id } = params; ... }.

@@ -0,0 +1,122 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { GET, POST, DELETE } from "@/app/api/assets/route";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for POST /api/assets to cover asset creation and validation failures.

POST is imported but never exercised. Since the handler writes to the DB and enforces required fields and auth, please add tests that:

  • Cover a successful creation for an authenticated user, including returned shape and defaulted fields.
  • Assert 400 responses for missing required fields (e.g. url, name) and expected error payload.
  • Assert unauthenticated/unauthorized responses when requireMutation reports authenticated: false.

Suggested implementation:

 // Mock auth
const mockRequireMutation = mock();
const mockAllowReadAccess = mock();
const mockGetSession = mock();

mock.module("@/lib/api-auth", () => ({
    requireMutation: mockRequireMutation,
    allowReadAccess: mockAllowReadAccess,
    GUEST_USER_ID: "guest-id"
}));

describe("POST /api/assets", () => {
    const baseUrl = "http://localhost/api/assets";

    const makePostRequest = (body: unknown) =>
        new NextRequest(baseUrl, {
            method: "POST",
            body: JSON.stringify(body),
            headers: {
                "content-type": "application/json"
            }
        });

    beforeEach(() => {
        mockRequireMutation.mockReset();
        mockAllowReadAccess.mockReset();
        mockGetSession.mockReset();
    });

    it("creates a new asset for an authenticated user and returns the created asset with defaulted fields", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: true,
            userId: "user-123"
        });

        const payload = {
            name: "Example asset",
            url: "https://example.com/asset.png"
        };

        const req = makePostRequest(payload);
        const res = await POST(req);

        expect(res.status).toBe(201);

        const json = await res.json();

        // Basic shape: echo user-specified fields and include server-generated defaults
        expect(json).toEqual(
            expect.objectContaining({
                name: payload.name,
                url: payload.url
            })
        );
        // Defaulted / server-populated fields
        expect(json).toHaveProperty("id");
        expect(json.id).toBeTruthy();
        expect(json).toHaveProperty("createdAt");
        expect(json).toHaveProperty("updatedAt");
    });

    it("returns 400 and validation errors when required fields are missing", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: true,
            userId: "user-123"
        });

        // Missing both `name` and `url`
        const invalidPayload = {};

        const req = makePostRequest(invalidPayload);
        const res = await POST(req);

        expect(res.status).toBe(400);

        const json = await res.json();

        // Expect a structured validation error payload
        expect(json).toHaveProperty("error");
        expect(json.error).toEqual(
            expect.objectContaining({
                message: expect.any(String)
            })
        );

        // If the handler returns field-specific errors, assert they include `name` and `url`
        if ("fieldErrors" in json.error) {
            expect(json.error.fieldErrors).toEqual(
                expect.objectContaining({
                    name: expect.any(Array),
                    url: expect.any(Array)
                })
            );
        }
    });

    it("returns an unauthenticated/unauthorized response when requireMutation reports authenticated: false", async () => {
        mockRequireMutation.mockResolvedValue({
            authenticated: false
        });

        const payload = {
            name: "Example asset",
            url: "https://example.com/asset.png"
        };

        const req = makePostRequest(payload);
        const res = await POST(req);

        // Use whatever status code the handler uses for unauthenticated requests (401 or 403 are typical)
        expect([401, 403]).toContain(res.status);

        const json = await res.json();

        expect(json).toHaveProperty("error");
        expect(json.error).toEqual(
            expect.objectContaining({
                message: expect.any(String)
            })
        );
    });

The above tests assume:

  1. requireMutation is async and returns an object with at least { authenticated: boolean, userId?: string }.
  2. The POST handler:
    • Returns 201 on success with a JSON body that includes id, name, url, createdAt, and updatedAt.
    • Returns 400 on validation errors with a JSON payload containing an error object, optionally with fieldErrors keyed by field name.
    • Returns 401 or 403 when authenticated: false.

If your actual handler differs, adjust:

  • The expected status codes in the assertions.
  • The shape of the JSON payloads (json.error, json.error.fieldErrors, defaulted fields).
    Also, if this test file already defines shared helpers for building NextRequest objects or common auth mocks, prefer reusing those instead of makePostRequest and/or the local beforeEach to stay consistent with the rest of the test suite.

Comment on lines +22 to +31
const mockDb = {
select: mock(() => mockDb),
from: mock(() => mockDb),
where: mock(() => mockDb),
limit: mock(() => mockDb),
offset: mock(() => mockDb),
orderBy: mock(() => mockDb),
insert: mock(() => mockDb),
values: mock(() => mockDb),
update: mock(() => mockDb),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): The current DB mocking pattern is likely to break the query chaining used in the handlers.

mockDb.select is initially a chainable mock (mock(() => mockDb)), but in "should query db for authenticated user" you later override it with mockReturnValueOnce([...]). At that point select returns an array instead of a query builder, so db.select().from().where() will fail when it tries to access from on the array, and the test will throw instead of exercising the handler logic.

To fix this, keep select returning a chainable object and only mock the terminal step of the chain. For example, either:

  • Keep select returning mockDb and add a terminal method (e.g. execute/all) whose return value you control per test; or
  • Have select return an object with a custom from/where chain whose final method returns the desired array.

The key is that mocks must preserve the Drizzle query-builder chaining contract while still allowing per-test data control.

});
});

describe("DELETE /api/assets", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add negative-path tests for DELETE /api/assets (missing id, unauthorized, or not owned).

Since the handler has explicit branches for missing id (400), auth failure (unauthenticated from requireMutation), and file not found / not owned (404), please add tests to cover these cases so error handling and status codes are verified and protected against regressions.

Suggested implementation:

    describe("DELETE /api/assets", () => {
        it("should delete file if owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            mockDb.select.mockReturnValue([{ id: "file-1", storageProvider: "local-fs" }]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(200);
            expect(data.success).toBe(true);
            expect(mockDb.delete).toHaveBeenCalled();
        });

        it("should return 400 if id is missing", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets");

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
            expect(mockDb.select).not.toHaveBeenCalled();
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 401 if user is not authenticated", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: false });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(401);
            expect(data.success).toBe(false);
            expect(mockDb.select).not.toHaveBeenCalled();
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 404 if file not found", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-unknown");

            mockDb.select.mockReturnValue([]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

        it("should return 404 if file is not owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });
            const req = new NextRequest("http://localhost/api/assets?id=file-1");

            mockDb.select.mockReturnValue([
                { id: "file-1", storageProvider: "local-fs", userId: "other-user" },
            ]);

            const res = await DELETE(req);
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
            expect(mockDb.delete).not.toHaveBeenCalled();
        });

These tests assume:

  • The DELETE handler returns a JSON body with { success: boolean } for both success and error cases.
  • The status codes are 400 for missing id, 401 for unauthenticated, and 404 for file not found or not owned.
  • mockDb.select returns an array of file records, optionally including a userId field used by the handler to check ownership.

If the actual handler uses different response shapes, field names (e.g., ownerId instead of userId), or status codes, adjust the expectations and mocked record shape accordingly to match the real implementation.

@@ -0,0 +1,122 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { GET, POST, DELETE } from "@/app/api/assets/route";
import { PATCH, GET as GET_ONE } from "@/app/api/assets/[id]/route";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for GET /api/assets/[id] or removing the unused import.

The test file imports GET as GET_ONE from @/app/api/assets/[id]/route, but doesn’t exercise the single-asset GET handler. Please either add coverage for GET /api/assets/:id (at least a happy path and a failure case such as not owned/not found/unauthorized/missing id) or remove the unused import to keep the tests focused.

Suggested change
import { PATCH, GET as GET_ONE } from "@/app/api/assets/[id]/route";
import { PATCH } from "@/app/api/assets/[id]/route";

});
});

describe("PATCH /api/assets/[id]", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for PATCH /api/assets/[id] validation and ownership checks.

The current test only covers the successful rename. Please also add tests for the other branches in this handler: missing id (400), no valid fields to update (both name and isPublic undefined → 400), file not found or not owned (404), and unauthorized access via requireMutation. This will better exercise the validation and ownership checks, especially for metadata-only updates and failure cases.

Suggested implementation:

    describe("PATCH /api/assets/[id]", () => {
        it("should update file name", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            mockDb.select.mockReturnValue([
                { id: "file-1", userId: "user-1", storageProvider: "local-fs", name: "old.jpg", isPublic: false },
            ]);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(200);
            expect(data.success).toBe(true);
            expect(mockDb.update).toHaveBeenCalledWith(
                expect.objectContaining({ name: "new-name.jpg" })
            );
        });

        it("should return 400 when id param is missing", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            const res = await PATCH(req, { params: {} as any });
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
        });

        it("should return 400 when no valid fields are provided", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({}),
            } as any);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(400);
            expect(data.success).toBe(false);
        });

        it("should return 404 when asset is not found or not owned by user", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: true, userId: "user-1" });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            mockDb.select.mockReturnValue([]); // not found / not owned

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(404);
            expect(data.success).toBe(false);
        });

        it("should return 401 when user is not authenticated", async () => {
            mockRequireMutation.mockResolvedValue({ authenticated: false, userId: null });

            const req = new NextRequest("http://localhost/api/assets/file-1", {
                method: "PATCH",
                body: JSON.stringify({ name: "new-name.jpg" }),
            } as any);

            const res = await PATCH(req, { params: { id: "file-1" } as any });
            const data = await res.json();

            expect(res.status).toBe(401);
            expect(data.success).toBe(false);
        });

These tests assume:

  1. PATCH is imported from the corresponding route file and accepts (req, { params }).
  2. The handler returns 400 for missing id and when both name and isPublic are absent, 404 when the asset is not found / not owned, and 401 when requireMutation indicates an unauthenticated user.
  3. The JSON response includes a success boolean.

If the actual handler uses different status codes or response shapes (e.g. 403 instead of 401, or no success flag), adjust the expect(res.status) and expect(data.success) assertions accordingly, and align the mockDb.update expectation with your real update call signature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant