feat(KB-225): add AI text refinement feature to dashboard

- Add AI text refinement backend service with OpenAI/Anthropic integration
- Add /api/refine-text API endpoint with error handling
- Add QuickEntryBox AI refine button with style presets menu
- Add NewTaskModal AI refine feature for task description editing
- Add comprehensive tests for backend service, API, and components
- Add changeset for the new feature
This commit is contained in:
gsxdsm
2026-03-31 04:20:37 -07:00
parent 139fbb9f29
commit f1a849b6fa
11 changed files with 1565 additions and 17 deletions

View File

@@ -1172,3 +1172,127 @@ describe("API Error Handling", () => {
});
});
});
// ── AI Text Refinement API Tests ───────────────────────────────────────────
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "./api";
describe("refineText", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST with text and type, returns refined text", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { refined: "Refined task description" })
);
const result = await refineText("Original text", "clarify");
expect(result).toBe("Refined task description");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/ai/refine-text", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Original text", type: "clarify" }),
});
});
it("works with all four refinement types", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { refined: "Refined" })
);
const types: RefinementType[] = ["clarify", "add-details", "expand", "simplify"];
for (const type of types) {
const result = await refineText("Test text", type);
expect(result).toBe("Refined");
}
});
it("throws on rate limit error (429)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 10 refinement requests per hour." }, 429)
);
await expect(refineText("Test", "clarify")).rejects.toThrow("Rate limit exceeded");
});
it("throws on invalid type error (422)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "type must be one of: clarify, add-details, expand, simplify" }, 422)
);
await expect(refineText("Test", "invalid" as RefinementType)).rejects.toThrow("type must be one of");
});
it("throws on validation error (400)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "text must be at least 1 character" }, 400)
);
await expect(refineText("", "clarify")).rejects.toThrow("text must be at least 1 character");
});
it("throws on server error (500)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "AI service error" }, 500)
);
await expect(refineText("Test", "clarify")).rejects.toThrow("AI service error");
});
});
describe("getRefineErrorMessage", () => {
it("returns rate limit message for rate limit errors", () => {
const error = new Error("Rate limit exceeded");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
});
it("returns rate limit message for 429 status", () => {
const error = new Error("429 Too Many Requests");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
});
it("returns invalid type message for invalid type errors", () => {
const error = new Error("Invalid type selected");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.INVALID_TYPE);
});
it("passes through text validation errors", () => {
const error = new Error("text must be at least 1 character");
expect(getRefineErrorMessage(error)).toBe("text must be at least 1 character");
});
it("passes through text length errors", () => {
const error = new Error("text must not exceed 2000 characters");
expect(getRefineErrorMessage(error)).toBe("text must not exceed 2000 characters");
});
it("passes through type required errors", () => {
const error = new Error("type is required");
expect(getRefineErrorMessage(error)).toBe("type is required");
});
it("returns network message for unknown errors", () => {
const error = new Error("Network failure");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
});
it("returns network message for non-Error values", () => {
expect(getRefineErrorMessage("string error")).toBe(REFINE_ERROR_MESSAGES.NETWORK);
expect(getRefineErrorMessage(null)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
expect(getRefineErrorMessage(undefined)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
});
});
describe("REFINE_ERROR_MESSAGES", () => {
it("has the expected error messages", () => {
expect(REFINE_ERROR_MESSAGES.RATE_LIMIT).toBe("Too many refinement requests. Please wait an hour.");
expect(REFINE_ERROR_MESSAGES.INVALID_TYPE).toBe("Invalid refinement option selected.");
expect(REFINE_ERROR_MESSAGES.NETWORK).toBe("Failed to refine text. Please try again.");
});
});