Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,74 @@ export async function selectTableName({
- All API routes should have JSDoc comments
- Run `pnpm lint` before committing

## Test-Driven Development (TDD)

**CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.**

### TDD Workflow

1. **Write failing tests first** - Create tests in `lib/[domain]/__tests__/[filename].test.ts` that describe the expected behavior
2. **Run tests to verify they fail** - `pnpm test path/to/test.ts`
3. **Implement the code** - Write the minimum code needed to make tests pass
4. **Run tests to verify they pass** - All tests should be green
5. **Refactor if needed** - Clean up while keeping tests green

### Test File Location

Tests live alongside the code they test:
```
lib/
├── chats/
│ ├── __tests__/
│ │ └── updateChatHandler.test.ts
│ ├── updateChatHandler.ts
│ └── validateUpdateChatBody.ts
```

### Test Pattern

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

// Mock dependencies
vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

describe("functionName", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("successful cases", () => {
it("does something when condition is met", async () => {
// Arrange
vi.mocked(dependency).mockResolvedValue(mockData);

// Act
const result = await functionName(input);

// Assert
expect(result.status).toBe(200);
});
});

describe("error cases", () => {
it("returns 400 when validation fails", async () => {
// Test error handling
});
});
});
```

### When to Write Tests

- **New API endpoints**: Write tests for all success and error paths
- **New handlers**: Test business logic with mocked dependencies
- **Bug fixes**: Write a failing test that reproduces the bug, then fix it
- **Validation functions**: Test all valid and invalid input combinations

## Authentication

**Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication:
Expand Down
19 changes: 19 additions & 0 deletions app/api/chats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createChatHandler } from "@/lib/chats/createChatHandler";
import { getChatsHandler } from "@/lib/chats/getChatsHandler";
import { updateChatHandler } from "@/lib/chats/updateChatHandler";

/**
* OPTIONS handler for CORS preflight requests.
Expand Down Expand Up @@ -52,3 +53,21 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
export async function POST(request: NextRequest): Promise<NextResponse> {
return createChatHandler(request);
}

/**
* PATCH /api/chats
*
* Update a chat room's topic (display name).
*
* Authentication: x-api-key header or Authorization Bearer token required.
*
* Body parameters:
* - chatId (required): UUID of the chat room to update
* - topic (required): New display name for the chat (3-50 characters)
*
* @param request - The request object
* @returns A NextResponse with the updated chat or an error
*/
export async function PATCH(request: NextRequest): Promise<NextResponse> {
return updateChatHandler(request);
}
167 changes: 167 additions & 0 deletions lib/chats/__tests__/updateChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { updateChatHandler } from "../updateChatHandler";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/chats/validateUpdateChatBody", () => ({
validateUpdateChatBody: vi.fn(),
}));

vi.mock("@/lib/supabase/rooms/updateRoom", () => ({
updateRoom: vi.fn(),
}));

import { validateUpdateChatBody } from "@/lib/chats/validateUpdateChatBody";
import { updateRoom } from "@/lib/supabase/rooms/updateRoom";

describe("updateChatHandler", () => {
const mockRequest = () => {
return new NextRequest("http://localhost/api/chats", {
method: "PATCH",
headers: { "x-api-key": "test-key", "Content-Type": "application/json" },
body: JSON.stringify({}),
});
};

beforeEach(() => {
vi.clearAllMocks();
});

describe("successful update", () => {
it("updates chat topic and returns success response", async () => {
const chatId = "123e4567-e89b-12d3-a456-426614174001";
const accountId = "123e4567-e89b-12d3-a456-426614174000";
const newTopic = "My Updated Chat";

vi.mocked(validateUpdateChatBody).mockResolvedValue({
chatId,
topic: newTopic,
});

vi.mocked(updateRoom).mockResolvedValue({
id: chatId,
account_id: accountId,
artist_id: null,
topic: newTopic,
updated_at: "2024-01-02T00:00:00Z",
});

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({
status: "success",
chat: {
id: chatId,
account_id: accountId,
topic: newTopic,
updated_at: "2024-01-02T00:00:00Z",
artist_id: null,
},
});

expect(updateRoom).toHaveBeenCalledWith(chatId, { topic: newTopic });
});
});

describe("validation errors", () => {
it("returns 400 from validation when body is invalid", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "chatId must be a valid UUID" },
{ status: 400 },
),
);

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(400);
expect(updateRoom).not.toHaveBeenCalled();
});

it("returns 401 from validation when auth fails", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "Unauthorized" },
{ status: 401 },
),
);

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(401);
expect(updateRoom).not.toHaveBeenCalled();
});

it("returns 404 from validation when chat not found", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "Chat room not found" },
{ status: 404 },
),
);

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(404);
expect(updateRoom).not.toHaveBeenCalled();
});

it("returns 403 from validation when access denied", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue(
NextResponse.json(
{ status: "error", error: "Access denied to this chat" },
{ status: 403 },
),
);

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(403);
expect(updateRoom).not.toHaveBeenCalled();
});
});

describe("update errors", () => {
it("returns 500 when updateRoom fails", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue({
chatId: "123e4567-e89b-12d3-a456-426614174001",
topic: "New Topic",
});

vi.mocked(updateRoom).mockResolvedValue(null);

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toContain("Failed to update");
});

it("returns 500 when updateRoom throws", async () => {
vi.mocked(validateUpdateChatBody).mockResolvedValue({
chatId: "123e4567-e89b-12d3-a456-426614174001",
topic: "New Topic",
});

vi.mocked(updateRoom).mockRejectedValue(new Error("Database error"));

const request = mockRequest();
const response = await updateChatHandler(request);

expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Database error");
});
});
});
Loading