Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1046f5b
Merge pull request #131 from Recoupable-com/test
sidneyswift Jan 17, 2026
0361e38
feat: add message persistence to /api/chat/generate
sweetmantech Jan 19, 2026
e875e24
refactor: extract saveChatCompletion utility (DRY)
sweetmantech Jan 19, 2026
a27c386
feat: auto-create roomId in validateChatRequest when not provided
sweetmantech Jan 19, 2026
944b012
test: update handleChatGenerate tests for auto-create roomId
sweetmantech Jan 19, 2026
90503c6
refactor: remove dead roomId conditional in handleChatGenerate
sweetmantech Jan 19, 2026
94d165f
feat: include roomId in handleChatGenerate HTTP response
sweetmantech Jan 19, 2026
691caf3
fix: persist user message when roomId is auto-created
sweetmantech Jan 19, 2026
93e4eeb
refactor: persist user message for ALL requests (match email flow)
sweetmantech Jan 19, 2026
bff86f6
refactor: DRY up conversation setup with shared setupConversation uti…
sweetmantech Jan 19, 2026
10ca01e
fix: persist last message instead of first to prevent duplicates
sweetmantech Jan 19, 2026
cda7427
feat: add convertToUiMessages utility and DRY up validateChatRequest
sweetmantech Jan 19, 2026
1a7caeb
refactor: extract isUiMessage type guard to own file (SRP)
sweetmantech Jan 19, 2026
600b5f9
refactor: use ModelMessage from AI SDK instead of custom SimpleMessage
sweetmantech Jan 19, 2026
dd75bd6
fix: add mocks to handleChatStream tests for Supabase dependencies
sweetmantech Jan 19, 2026
db0ad7d
refactor: simplify convertToUiMessages (KISS)
sweetmantech Jan 19, 2026
380e96b
fix: handle ModelMessage content as string or parts array
sweetmantech Jan 19, 2026
d6b9f61
refactor: extract getTextContent to own file (SRP)
sweetmantech Jan 19, 2026
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
244 changes: 244 additions & 0 deletions lib/chat/__tests__/handleChatGenerate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,46 @@ vi.mock("ai", () => ({
generateText: vi.fn(),
}));

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

vi.mock("@/lib/uuid/generateUUID", () => {
const mockFn = vi.fn(() => "auto-generated-room-id");
return {
generateUUID: mockFn,
default: mockFn,
};
});

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

vi.mock("@/lib/supabase/memories/insertMemories", () => ({
default: vi.fn(),
}));

vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({
default: vi.fn((msg: unknown) => msg),
}));

import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
import { setupChatRequest } from "@/lib/chat/setupChatRequest";
import { generateText } from "ai";
import { saveChatCompletion } from "@/lib/chat/saveChatCompletion";
import { generateUUID } from "@/lib/uuid/generateUUID";
import { createNewRoom } from "@/lib/chat/createNewRoom";
import { handleChatGenerate } from "../handleChatGenerate";

const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId);
const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId);
const mockSetupChatRequest = vi.mocked(setupChatRequest);
const mockGenerateText = vi.mocked(generateText);
const mockSaveChatCompletion = vi.mocked(saveChatCompletion);
const mockGenerateUUID = vi.mocked(generateUUID);
const mockCreateNewRoom = vi.mocked(createNewRoom);

// Helper to create mock NextRequest
function createMockRequest(
Expand Down Expand Up @@ -336,4 +366,218 @@ describe("handleChatGenerate", () => {
);
});
});

describe("message persistence", () => {
it("saves assistant message to database when roomId is provided", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Hello! How can I help you?",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-abc-123" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

expect(mockSaveChatCompletion).toHaveBeenCalledWith({
text: "Hello! How can I help you?",
roomId: "room-abc-123",
});
});

it("saves message with auto-generated roomId when roomId is not provided", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");
mockGenerateUUID.mockReturnValue("auto-generated-room-id");
mockCreateNewRoom.mockResolvedValue(undefined);

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

// Since roomId is auto-created, saveChatCompletion should be called
expect(mockSaveChatCompletion).toHaveBeenCalledWith({
text: "Response",
roomId: "auto-generated-room-id",
});
});

it("includes roomId in HTTP response when provided by client", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello", roomId: "client-provided-room-id" },
{ "x-api-key": "valid-key" },
);

const result = await handleChatGenerate(request as any);

expect(result.status).toBe(200);
const json = await result.json();
expect(json.roomId).toBe("client-provided-room-id");
});

it("includes auto-generated roomId in HTTP response when not provided", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");
mockGenerateUUID.mockReturnValue("auto-generated-room-456");
mockCreateNewRoom.mockResolvedValue(undefined);

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello" },
{ "x-api-key": "valid-key" },
);

const result = await handleChatGenerate(request as any);

expect(result.status).toBe(200);
const json = await result.json();
expect(json.roomId).toBe("auto-generated-room-456");
});

it("passes correct text to saveChatCompletion", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "This is the assistant response text",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockResolvedValue(null);

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-xyz" },
{ "x-api-key": "valid-key" },
);

await handleChatGenerate(request as any);

expect(mockSaveChatCompletion).toHaveBeenCalledWith({
text: "This is the assistant response text",
roomId: "room-xyz",
});
});

it("still returns success response even if saveChatCompletion fails", async () => {
mockGetApiKeyAccountId.mockResolvedValue("account-123");

mockSetupChatRequest.mockResolvedValue({
model: "gpt-4",
instructions: "test",
system: "test",
messages: [],
experimental_generateMessageId: vi.fn(),
tools: {},
providerOptions: {},
} as any);

mockGenerateText.mockResolvedValue({
text: "Response",
finishReason: "stop",
usage: { promptTokens: 10, completionTokens: 20 },
response: { messages: [], headers: {}, body: null },
} as any);

mockSaveChatCompletion.mockRejectedValue(new Error("Database error"));

const request = createMockRequest(
{ prompt: "Hello", roomId: "room-abc" },
{ "x-api-key": "valid-key" },
);

const result = await handleChatGenerate(request as any);

expect(result.status).toBe(200);
const json = await result.json();
expect(json.text).toBe("Response");
});
});
});
27 changes: 27 additions & 0 deletions lib/chat/__tests__/handleChatStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,29 @@ vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({
validateOrganizationAccess: vi.fn(),
}));

vi.mock("@/lib/chat/setupConversation", () => ({
setupConversation: vi.fn().mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }),
}));

vi.mock("@/lib/chat/validateMessages", () => ({
validateMessages: vi.fn((messages) => ({
lastMessage: messages[messages.length - 1] || { id: "mock-id", role: "user", parts: [] },
validMessages: messages,
})),
}));

vi.mock("@/lib/messages/convertToUiMessages", () => ({
default: vi.fn((messages) => messages),
}));

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

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

vi.mock("ai", () => ({
createUIMessageStream: vi.fn(),
createUIMessageStreamResponse: vi.fn(),
Expand All @@ -34,11 +53,13 @@ vi.mock("ai", () => ({
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId";
import { setupChatRequest } from "@/lib/chat/setupChatRequest";
import { setupConversation } from "@/lib/chat/setupConversation";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
import { handleChatStream } from "../handleChatStream";

const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId);
const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId);
const mockSetupConversation = vi.mocked(setupConversation);
const mockSetupChatRequest = vi.mocked(setupChatRequest);
const mockCreateUIMessageStream = vi.mocked(createUIMessageStream);
const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse);
Expand All @@ -60,6 +81,12 @@ function createMockRequest(
describe("handleChatStream", () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-setup mock return value after clearAllMocks
// Return the provided roomId if given, otherwise return mock-room-id
mockSetupConversation.mockImplementation(async ({ roomId }) => ({
roomId: roomId || "mock-room-id",
memoryId: "mock-memory-id",
}));
});

afterEach(() => {
Expand Down
25 changes: 25 additions & 0 deletions lib/chat/__tests__/integration/chatEndToEnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,31 @@ vi.mock("@/lib/chat/generateChatTitle", () => ({
generateChatTitle: vi.fn().mockResolvedValue("Test Chat"),
}));

// Mock room creation dependencies (for auto-create roomId)
vi.mock("@/lib/uuid/generateUUID", () => {
const mockFn = vi.fn(() => "mock-uuid-default");
return {
generateUUID: mockFn,
default: mockFn,
};
});

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

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

vi.mock("@/lib/supabase/memories/insertMemories", () => ({
default: vi.fn(),
}));

vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({
default: vi.fn((msg: unknown) => msg),
}));

// Mock AI SDK
vi.mock("ai", () => ({
convertToModelMessages: vi.fn((messages: unknown[]) => messages),
Expand Down
Loading