Skip to content
Open
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
135 changes: 135 additions & 0 deletions app/api/memories/get/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "../route";

const mockValidateHeaders = vi.fn();
const mockGetRoom = vi.fn();
const mockQueryMemories = vi.fn();

vi.mock("@/lib/chat/validateHeaders", () => ({
validateHeaders: (...args: unknown[]) => mockValidateHeaders(...args),
}));

vi.mock("@/lib/supabase/getRoom", () => ({
default: (...args: unknown[]) => mockGetRoom(...args),
}));

vi.mock("@/lib/supabase/queryMemories", () => ({
default: (...args: unknown[]) => mockQueryMemories(...args),
}));

describe("GET /api/memories/get", () => {
const roomId = "11111111-1111-1111-1111-111111111111";
const accountId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";

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

it("returns 400 when roomId is missing", async () => {
const req = new NextRequest("https://example.com/api/memories/get");
const res = await GET(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("Room ID is required");
});

it("returns 401 when not authenticated", async () => {
mockValidateHeaders.mockResolvedValueOnce({});

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
);
const res = await GET(req);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});

it("forwards validateHeaders error Response", async () => {
const errorRes = new Response(JSON.stringify({ status: "error" }), {
status: 401,
});
mockValidateHeaders.mockResolvedValueOnce(errorRes);

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
{ headers: { Authorization: "Bearer bad" } },
);
const res = await GET(req);
expect(res.status).toBe(401);
});

it("returns 404 when room does not exist", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId });
mockGetRoom.mockResolvedValueOnce(null);

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
{ headers: { Authorization: "Bearer token" } },
);
const res = await GET(req);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Room not found");
});

it("returns 403 when room belongs to another account", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId });
mockGetRoom.mockResolvedValueOnce({
id: roomId,
account_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
});

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
{ headers: { Authorization: "Bearer token" } },
);
const res = await GET(req);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("Forbidden");
});

it("returns 200 with memories when caller owns the room", async () => {
const memories = [{ id: "m1", room_id: roomId, content: {}, updated_at: "" }];
mockValidateHeaders.mockResolvedValueOnce({ accountId });
mockGetRoom.mockResolvedValueOnce({
id: roomId,
account_id: accountId,
});
mockQueryMemories.mockResolvedValueOnce({
data: memories,
error: null,
});

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
{ headers: { Authorization: "Bearer token" } },
);
const res = await GET(req);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual(memories);
expect(mockQueryMemories).toHaveBeenCalledWith(roomId, { ascending: true });
});

it("returns 400 when queryMemories fails", async () => {
mockValidateHeaders.mockResolvedValueOnce({ accountId });
mockGetRoom.mockResolvedValueOnce({
id: roomId,
account_id: accountId,
});
mockQueryMemories.mockResolvedValueOnce({
data: null,
error: { message: "db error" },
});

const req = new NextRequest(
`https://example.com/api/memories/get?roomId=${roomId}`,
{ headers: { Authorization: "Bearer token" } },
);
const res = await GET(req);
expect(res.status).toBe(400);
});
});
20 changes: 19 additions & 1 deletion app/api/memories/get/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { NextRequest } from "next/server";
import queryMemories from "@/lib/supabase/queryMemories";
import { validateHeaders } from "@/lib/chat/validateHeaders";
import getRoom from "@/lib/supabase/getRoom";

export async function GET(req: NextRequest) {
const roomId = req.nextUrl.searchParams.get("roomId");
Expand All @@ -8,9 +10,25 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Room ID is required" }, { status: 400 });
}

const authResult = await validateHeaders(req);
if (authResult instanceof Response) {
return authResult;
}
if (!authResult.accountId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const room = await getRoom(roomId);
if (!room) {
return Response.json({ error: "Room not found" }, { status: 404 });
Comment on lines +21 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don’t map room lookup failures to 404

The handler treats any falsy getRoom result as "Room not found", but getRoom returns null for all Supabase errors as well as missing rows. That means transient DB/query failures are now reported as 404 instead of a server error, which can mislead clients and hide operational issues.

Useful? React with 👍 / 👎.

}
if (room.account_id !== authResult.accountId) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
Comment on lines +18 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unify error response shape across this route.

Early exits return { error: ... }, but the catch path returns { message: ... }. This creates an inconsistent API contract for clients handling failures.

Proposed fix
 export async function GET(req: NextRequest) {
+  const jsonError = (status: number, error: string) =>
+    Response.json({ error }, { status });
+
   const roomId = req.nextUrl.searchParams.get("roomId");

   if (!roomId) {
-    return Response.json({ error: "Room ID is required" }, { status: 400 });
+    return jsonError(400, "Room ID is required");
   }
@@
   if (!authResult.accountId) {
-    return Response.json({ error: "Unauthorized" }, { status: 401 });
+    return jsonError(401, "Unauthorized");
   }
@@
   if (!room) {
-    return Response.json({ error: "Room not found" }, { status: 404 });
+    return jsonError(404, "Room not found");
   }
   if (room.account_id !== authResult.accountId) {
-    return Response.json({ error: "Forbidden" }, { status: 403 });
+    return jsonError(403, "Forbidden");
   }
@@
   } catch (error) {
     console.error("[api/memories/get] Error:", error);
     const message = error instanceof Error ? error.message : "failed";
-    return Response.json({ message }, { status: 400 });
+    return jsonError(400, message);
   }
 }

As per coding guidelines, API routes must “Return consistent response formats”.

Also applies to: 40-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/memories/get/route.ts` around lines 18 - 27, The route returns errors
with two different shapes ({ error: ... } in early exits vs { message: ... } in
the catch), so change the catch-path response to use the same error key and
shape as the rest of the handler; locate the handler in
app/api/memories/get/route.ts (references: getRoom, authResult, Response.json)
and update the catch block(s) that return { message: ... } to return { error:
"<same content>" } with the appropriate HTTP status, and ensure any other
error-returning lines (including the one around line 40) follow this unified {
error: ... } format.


try {
const { data, error } = await queryMemories(roomId, { ascending: true });

if (error) {
throw error;
}
Expand Down
13 changes: 10 additions & 3 deletions hooks/useMessageLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import getClientMessages from "@/lib/supabase/getClientMessages";
* @param roomId - The room ID to load messages from (undefined to skip loading)
* @param userId - The current user ID (messages won't load if user is not authenticated)
* @param setMessages - Callback function to set the loaded messages
* @param accessToken - Privy access token for /api/memories/get (required for auth)
* @returns Loading state and error information
*/
export function useMessageLoader(
roomId: string | undefined,
userId: string | undefined,
setMessages: (messages: UIMessage[]) => void
setMessages: (messages: UIMessage[]) => void,
accessToken: string | null,
) {
const [isLoading, setIsLoading] = useState(!!roomId);
const [error, setError] = useState<Error | null>(null);
Expand All @@ -28,12 +30,17 @@ export function useMessageLoader(
return;
}

if (!accessToken) {
setIsLoading(true);
return;
Comment on lines +33 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop spinner when access token cannot be retrieved

This branch keeps isLoading stuck at true whenever accessToken is falsy, but never sets an error or terminal state. If useAccessToken() returns null (e.g., token fetch fails or user session is partially initialized), the chat page remains on the loading skeleton indefinitely with no recovery path. This became user-visible after making message loading depend on the token.

Useful? React with 👍 / 👎.

}
Comment on lines +33 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid permanent loading state when accessToken is missing.

This branch sets loading to true and exits, so if token retrieval fails or user stays unauthenticated, loading can remain stuck.

Proposed fix
     if (!accessToken) {
-      setIsLoading(true);
+      setIsLoading(false);
+      setError(new Error("Authentication required to load messages"));
       return;
     }

As per coding guidelines, hooks should “Handle edge cases and errors”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/useMessageLoader.ts` around lines 33 - 36, The hook useMessageLoader
sets setIsLoading(true) and immediately returns when accessToken is falsy, which
can leave the component permanently loading; update the early-accessToken branch
in useMessageLoader to clear the loading state and handle the unauthenticated
case (e.g., call setIsLoading(false) before return and optionally set an error
or empty messages via setHasError/setMessages) so the hook doesn't remain stuck
when accessToken is missing.


const loadMessages = async () => {
setIsLoading(true);
setError(null);

try {
const initialMessages = await getClientMessages(roomId);
const initialMessages = await getClientMessages(roomId, accessToken);
if (initialMessages.length > 0) {
setMessages(initialMessages as UIMessage[]);
}
Expand All @@ -48,7 +55,7 @@ export function useMessageLoader(
};

loadMessages();
}, [userId, roomId]);
}, [userId, roomId, accessToken]);

return {
isLoading,
Expand Down
1 change: 1 addition & 0 deletions hooks/useVercelChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export function useVercelChat({
messages.length === 0 ? id : undefined,
userId,
setMessages,
accessToken,
);

// Only show loading state if:
Expand Down
8 changes: 6 additions & 2 deletions lib/supabase/getClientMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const getClientMessages = async (chatId: string) => {
const getClientMessages = async (chatId: string, accessToken: string) => {
try {
const response = await fetch(`/api/memories/get?roomId=${chatId}`);
const response = await fetch(`/api/memories/get?roomId=${chatId}`, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Encode chatId before composing the query string.

chatId should be URL-encoded to avoid malformed requests for non-UUID IDs.

Proposed fix
-    const response = await fetch(`/api/memories/get?roomId=${chatId}`, {
+    const response = await fetch(
+      `/api/memories/get?roomId=${encodeURIComponent(chatId)}`,
+      {
       headers: {
         Authorization: `Bearer ${accessToken}`,
       },
-    });
+      },
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(`/api/memories/get?roomId=${chatId}`, {
const response = await fetch(
`/api/memories/get?roomId=${encodeURIComponent(chatId)}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/getClientMessages.tsx` at line 3, The request URL uses chatId
directly which can break for non-UUID IDs; update the fetch call that constructs
`/api/memories/get?roomId=${chatId}` (in getClientMessages / the function
performing this fetch) to URL-encode the chatId using encodeURIComponent(chatId)
when composing the query string so the resulting request is always well-formed.

headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const data = await response.json();

const memories = data?.data || [];
Expand Down
Loading