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
24 changes: 24 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Summary

<!-- 1-3 bullet points explaining what this PR does and why -->

## Changes

<!-- Describe the changes made in this PR -->

## Testing

<!-- How was this tested? What test cases were added/updated? -->

## 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

<!-- Optional: any blockers, assumptions, or follow-up work -->
111 changes: 111 additions & 0 deletions lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
174 changes: 174 additions & 0 deletions lib/__tests__/display.test.ts
Original file line number Diff line number Diff line change
@@ -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("<p>Hello</p> <b>World</b>")).toBe("Hello World");
});

it("removes script tags and their content interpretation", () => {
const input = "Safe<script>alert('xss')</script>Text";
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 = "<p>Hello\x00World</p>" + "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 = "Hello<br/> \n\n World";
expect(sanitizeDisplayText(input)).toBe("Hello World");
});
});

// ---------------------------------------------------------------------------
// getDisplayText — wrapper with fallback
// ---------------------------------------------------------------------------
describe("getDisplayText", () => {
it("returns sanitized text when text is non-empty", () => {
expect(getDisplayText("Hello World")).toBe("Hello World");
});

it("returns fallback when text is null", () => {
expect(getDisplayText(null)).toBe("Untitled");
});

it("returns fallback when text is undefined", () => {
expect(getDisplayText(undefined)).toBe("Untitled");
});

it("returns fallback when text is empty string", () => {
expect(getDisplayText("")).toBe("Untitled");
});

it("returns fallback when text is whitespace-only", () => {
expect(getDisplayText(" \n ")).toBe("Untitled");
});

it("uses custom fallback when provided", () => {
expect(getDisplayText(null, "No Title")).toBe("No Title");
expect(getDisplayText("", "Custom Default")).toBe("Custom Default");
});

it("applies sanitization and uses fallback independently", () => {
expect(getDisplayText("<p></p>", "Empty")).toBe("Empty");
expect(getDisplayText("<p>Content</p>")).toBe("Content");
});

it("sanitizes text before checking for fallback", () => {
const allTags = "<p></p><div></div>";
expect(getDisplayText(allTags)).toBe("Untitled");
});

it("preserves structure after sanitization", () => {
const result = getDisplayText("Line1\nLine2\nLine3");
expect(result).toBe("Line1\nLine2\nLine3");
});
});