diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..844bc9b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Summary + + + +## Changes + + + +## Testing + + + +## Checklist + +- [ ] **Tests added or updated** — Pure-function tests in `lib/__tests__/*.test.ts` follow `lib/__tests__/format.test.ts` pattern; component tests in `components/**/*.test.tsx` use `@testing-library/react` with `screen.getByRole()` for accessibility-first assertions +- [ ] **Accessible interactive elements** — New interactive elements (buttons, inputs, links, menus) have appropriate ARIA roles and accessible names; verified via `screen.getByRole()` in tests +- [ ] **Typecheck passes** — `npm run typecheck` produces no errors +- [ ] **Lint passes** — `npm run lint` produces no errors +- [ ] **Tests pass** — `npm test` passes, new tests verify the change works +- [ ] **Build succeeds** — `npm run build` completes without errors + +## Notes + + diff --git a/lib/__tests__/api.test.ts b/lib/__tests__/api.test.ts new file mode 100644 index 0000000..fecbdee --- /dev/null +++ b/lib/__tests__/api.test.ts @@ -0,0 +1,111 @@ +import { api } from "@/lib/api"; + +// Mock fetch globally +global.fetch = jest.fn(); + +describe("api client error handling", () => { + beforeEach(() => { + (global.fetch as jest.Mock).mockClear(); + }); + + // --------------------------------------------------------------------------- + // Non-200 response handling + // --------------------------------------------------------------------------- + + it("returns null for non-200 responses", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const result = await api.getStats(); + expect(result).toBeNull(); + }); + + it("returns null for 500 server errors", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const result = await api.getStats(); + expect(result).toBeNull(); + }); + + it("handles 401 unauthorized without throwing", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + const result = await api.getStats(); + expect(result).toBeNull(); + }); + + // --------------------------------------------------------------------------- + // Malformed response body handling + // --------------------------------------------------------------------------- + + it("throws error when response body is not valid JSON", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValueOnce(new SyntaxError("Unexpected token < in JSON at position 0")), + }); + + // This test documents the current buggy behavior: malformed JSON causes unhandled error + await expect(api.getStats()).rejects.toThrow(SyntaxError); + }); + + it("throws error when response body claims JSON but contains HTML (e.g., error page)", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValueOnce(new SyntaxError("Unexpected token < in JSON at position 0")), + }); + + await expect(api.getStats()).rejects.toThrow(SyntaxError); + }); + + it("throws error for unexpectedly-shaped JSON response", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ unexpected: "shape" }), + }); + + // Current behavior: no validation of response shape, just passes through + const result = await api.getStats(); + // Type-wise this is { unexpected: "shape" } not ApiStatsResult, but no runtime error + expect(result).toEqual({ unexpected: "shape" }); + }); + + // --------------------------------------------------------------------------- + // Successful responses + // --------------------------------------------------------------------------- + + it("returns parsed JSON for successful response", async () => { + const mockData = { + totalAssets: 10, + tvl: "1000000000", + totalHolders: 250, + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockData), + }); + + const result = await api.getStats(); + expect(result).toEqual(mockData); + }); + + it("returns null when URL is empty", async () => { + // When BASE is not set, apiUrl returns empty string + const originalEnv = process.env.NEXT_PUBLIC_API_URL; + delete process.env.NEXT_PUBLIC_API_URL; + + const result = await api.getStats(); + expect(result).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + + process.env.NEXT_PUBLIC_API_URL = originalEnv; + }); +}); diff --git a/lib/__tests__/display.test.ts b/lib/__tests__/display.test.ts new file mode 100644 index 0000000..fc2f324 --- /dev/null +++ b/lib/__tests__/display.test.ts @@ -0,0 +1,174 @@ +import { sanitizeDisplayText, getDisplayText } from "@/lib/display"; + +// --------------------------------------------------------------------------- +// sanitizeDisplayText — truncation, sanitization, and edge cases +// --------------------------------------------------------------------------- +describe("sanitizeDisplayText", () => { + // --- character and tag removal ------------------------------------------- + + it("removes control characters (0x00-0x1F, 0x7F)", () => { + const input = "Hello\x00World\x1FTest"; + expect(sanitizeDisplayText(input)).toBe("HelloWorldTest"); + }); + + it("removes HTML tags", () => { + expect(sanitizeDisplayText("
Hello
World")).toBe("Hello World"); + }); + + it("removes script tags and their content interpretation", () => { + const input = "SafeText"; + expect(sanitizeDisplayText(input)).toBe("SafeText"); + }); + + it("normalizes multiple spaces to single space", () => { + expect(sanitizeDisplayText("Hello World Test")).toBe("Hello World Test"); + }); + + // --- truncation by length ------------------------------------------------ + + it("truncates text longer than maxLength to specified length", () => { + const longText = "a".repeat(250); + const result = sanitizeDisplayText(longText, { maxLength: 220 }); + expect(result.length).toBe(221); // 220 chars + "…" ellipsis + expect(result.endsWith("…")).toBe(true); + }); + + it("uses default maxLength of 220 when not specified", () => { + const longText = "x".repeat(250); + const result = sanitizeDisplayText(longText); + expect(result.length).toBe(221); // 220 + "…" + }); + + it("does not add ellipsis if text is shorter than maxLength", () => { + const text = "Short text"; + expect(sanitizeDisplayText(text, { maxLength: 220 })).toBe("Short text"); + expect(sanitizeDisplayText(text)).toBe("Short text"); + }); + + it("trims trailing whitespace before adding ellipsis", () => { + const text = "a".repeat(220) + " "; + const result = sanitizeDisplayText(text, { maxLength: 220 }); + expect(result).toBe("a".repeat(220) + "…"); + expect(result).not.toMatch(/\s…$/); + }); + + // --- truncation by line count -------------------------------------------- + + it("collapses lines to maxLines (default 8)", () => { + const nineLines = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9"; + const result = sanitizeDisplayText(nineLines); + const lineCount = result.split("\n").length; + expect(lineCount).toBe(8); + }); + + it("preserves lines up to maxLines", () => { + const threeLines = "line1\nline2\nline3"; + expect(sanitizeDisplayText(threeLines, { maxLines: 3 })).toBe("line1\nline2\nline3"); + }); + + it("trims each line individually", () => { + const input = " line1 \n line2 \n line3 "; + const result = sanitizeDisplayText(input, { maxLines: 3 }); + const lines = result.split("\n"); + expect(lines[0]).toBe("line1"); + expect(lines[1]).toBe("line2"); + expect(lines[2]).toBe("line3"); + }); + + it("filters out empty lines", () => { + const input = "line1\n\n\nline2\n"; + const result = sanitizeDisplayText(input, { maxLines: 10 }); + expect(result).toBe("line1\nline2"); + }); + + // --- empty and short input ----------------------------------------------- + + it("returns empty string for null input", () => { + expect(sanitizeDisplayText(null)).toBe(""); + }); + + it("returns empty string for undefined input", () => { + expect(sanitizeDisplayText(undefined)).toBe(""); + }); + + it("returns empty string for whitespace-only input", () => { + expect(sanitizeDisplayText(" \n\n ")).toBe(""); + }); + + it("handles single character input", () => { + expect(sanitizeDisplayText("a")).toBe("a"); + }); + + it("handles single-character input at truncation boundary", () => { + // Single char should not be truncated + const result = sanitizeDisplayText("x", { maxLength: 5 }); + expect(result).toBe("x"); + }); + + // --- combined scenarios -------------------------------------------------- + + it("truncates by length and line count together", () => { + const input = "a".repeat(300) + "\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9"; + const result = sanitizeDisplayText(input, { maxLength: 220, maxLines: 5 }); + expect(result.split("\n").length).toBe(5); + expect(result).toContain("…"); + }); + + it("handles text with tags, control chars, and truncation", () => { + const input = "Hello\x00World
" + "x".repeat(250); + const result = sanitizeDisplayText(input, { maxLength: 50 }); + expect(result).toMatch(/^Hello World/); + expect(result.length).toBe(51); // 50 + "…" + expect(result).toContain("…"); + }); + + it("normalizes whitespace after removing tags", () => { + const input = "HelloContent
")).toBe("Content"); + }); + + it("sanitizes text before checking for fallback", () => { + const allTags = ""; + expect(getDisplayText(allTags)).toBe("Untitled"); + }); + + it("preserves structure after sanitization", () => { + const result = getDisplayText("Line1\nLine2\nLine3"); + expect(result).toBe("Line1\nLine2\nLine3"); + }); +});