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
13 changes: 4 additions & 9 deletions app/api/accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@ import {
validateCreateAccountBody,
type CreateAccountBody,
} from "@/lib/accounts/validateCreateAccountBody";
import {
validateUpdateAccountBody,
type UpdateAccountBody,
} from "@/lib/accounts/validateUpdateAccountBody";
import { createAccountHandler } from "@/lib/accounts/createAccountHandler";
import { updateAccountHandler } from "@/lib/accounts/updateAccountHandler";
import { validateUpdateAccountRequest } from "@/lib/accounts/validateUpdateAccountRequest";

/**
* POST /api/accounts
Expand All @@ -37,20 +34,18 @@ export async function POST(req: NextRequest) {
* PATCH /api/accounts
*
* Update an existing account's profile information.
* Requires accountId in the body along with fields to update.
* Requires authentication and a permitted accountId in the body.
*
* @param req - The incoming request with accountId and update fields
* @returns NextResponse with updated account data or error
*/
export async function PATCH(req: NextRequest) {
const body = await safeParseJson(req);

const validated = validateUpdateAccountBody(body);
const validated = await validateUpdateAccountRequest(req);
if (validated instanceof NextResponse) {
return validated;
}

return updateAccountHandler(validated as UpdateAccountBody);
return updateAccountHandler(validated);
}

/**
Expand Down
149 changes: 149 additions & 0 deletions lib/accounts/__tests__/validateUpdateAccountRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateUpdateAccountRequest } from "../validateUpdateAccountRequest";

const mockValidateAuthContext = vi.fn();

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args),
}));

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

vi.mock("@/lib/networking/safeParseJson", () => ({
safeParseJson: vi.fn(async (req: Request) => req.json()),
}));

function createRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/accounts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-token",
},
body: JSON.stringify(body),
});
}

describe("validateUpdateAccountRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mockValidateAuthContext.mockResolvedValue({
accountId: "550e8400-e29b-41d4-a716-446655440000",
orgId: null,
authToken: "test-token",
});
});

it("returns validated payload for a valid request", async () => {
const request = createRequest({
accountId: "550e8400-e29b-41d4-a716-446655440000",
name: "Test User",
knowledges: [
{
name: "Strategy",
url: "https://example.com/strategy.pdf",
type: "application/pdf",
},
],
});

const result = await validateUpdateAccountRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result).toEqual({
accountId: "550e8400-e29b-41d4-a716-446655440000",
name: "Test User",
knowledges: [
{
name: "Strategy",
url: "https://example.com/strategy.pdf",
type: "application/pdf",
},
],
});
}
expect(mockValidateAuthContext).toHaveBeenCalledWith(request, {
accountId: "550e8400-e29b-41d4-a716-446655440000",
});
});

it("replaces accountId with the validated override account", async () => {
mockValidateAuthContext.mockResolvedValue({
accountId: "660e8400-e29b-41d4-a716-446655440001",
orgId: "org-123",
authToken: "test-token",
});

const request = createRequest({
accountId: "550e8400-e29b-41d4-a716-446655440000",
name: "Org Member",
});

const result = await validateUpdateAccountRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.accountId).toBe("660e8400-e29b-41d4-a716-446655440001");
}
});

it("returns 400 when knowledges payload is invalid", async () => {
const request = createRequest({
accountId: "550e8400-e29b-41d4-a716-446655440000",
knowledges: ["not-an-object"],
});

const result = await validateUpdateAccountRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(400);
}
});

it("succeeds without accountId in body (derives from auth)", async () => {
const request = createRequest({
name: "Updated Name",
});

const result = await validateUpdateAccountRequest(request);

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(result.name).toBe("Updated Name");
}
});

it("returns 400 when no updatable fields are provided", async () => {
const request = createRequest({});

const result = await validateUpdateAccountRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(400);
}
});

it("returns auth error response when auth fails", async () => {
mockValidateAuthContext.mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const request = createRequest({
name: "Test",
});

const result = await validateUpdateAccountRequest(request);

expect(result).toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) {
expect(result.status).toBe(401);
}
});
});
6 changes: 4 additions & 2 deletions lib/accounts/updateAccountHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import { updateAccount } from "@/lib/supabase/accounts/updateAccount";
import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo";
import { updateAccountInfo } from "@/lib/supabase/account_info/updateAccountInfo";
import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo";
import type { UpdateAccountBody } from "./validateUpdateAccountBody";
import type { ValidatedUpdateAccountRequest } from "./validateUpdateAccountRequest";

/**
* Handles PATCH /api/accounts - Update account profile information.
*
* @param body - Validated request body with accountId and fields to update
* @returns NextResponse with updated account data
*/
export async function updateAccountHandler(body: UpdateAccountBody): Promise<NextResponse> {
export async function updateAccountHandler(
body: ValidatedUpdateAccountRequest,
): Promise<NextResponse> {
const {
accountId,
name,
Expand Down
44 changes: 0 additions & 44 deletions lib/accounts/validateUpdateAccountBody.ts

This file was deleted.

83 changes: 83 additions & 0 deletions lib/accounts/validateUpdateAccountRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { safeParseJson } from "@/lib/networking/safeParseJson";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { z } from "zod";

const knowledgeSchema = z.object({
name: z.string(),
url: z.string().url("knowledges.url must be a valid URL"),
type: z.string(),
});

export const updateAccountBodySchema = z
.object({
accountId: z.string().uuid("accountId must be a valid UUID").optional(),
name: z.string().optional(),
instruction: z.string().optional(),
organization: z.string().optional(),
image: z.string().url("image must be a valid URL").optional().or(z.literal("")),
jobTitle: z.string().optional(),
roleType: z.string().optional(),
companyName: z.string().optional(),
knowledges: z.array(knowledgeSchema).optional(),
})
.refine(
data => {
const { accountId: _, ...fields } = data;
return Object.values(fields).some(v => v !== undefined);
},
{ message: "At least one field to update must be provided" },
);

export type ValidatedUpdateAccountRequest = Omit<
z.infer<typeof updateAccountBodySchema>,
"accountId"
> & {
accountId: string;
};

/**
* Validates PATCH /api/accounts including auth, account override access, and body schema.
*/
export async function validateUpdateAccountRequest(
request: NextRequest,
): Promise<NextResponse | ValidatedUpdateAccountRequest> {
const body = await safeParseJson(request);
const result = updateAccountBodySchema.safeParse(body);

if (!result.success) {
const firstError = result.error.issues[0];
return NextResponse.json(
{
status: "error",
missing_fields: firstError.path,
error: firstError.message,
},
{
status: 400,
headers: getCorsHeaders(),
},
);
}

const authContext = await validateAuthContext(request, {
accountId: result.data.accountId,
});

if (authContext instanceof NextResponse) {
return authContext;
}

return {
accountId: authContext.accountId,
name: result.data.name,
instruction: result.data.instruction,
organization: result.data.organization,
image: result.data.image,
jobTitle: result.data.jobTitle,
roleType: result.data.roleType,
companyName: result.data.companyName,
knowledges: result.data.knowledges,
};
}
Loading