diff --git a/src/components/AIMentorWidget.tsx b/src/components/AIMentorWidget.tsx index 9e360a73..786f14d0 100644 --- a/src/components/AIMentorWidget.tsx +++ b/src/components/AIMentorWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import DOMPurify from "dompurify"; interface Insight { diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index 8e8ee1ad..e60a2426 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -27,17 +27,17 @@ export default function WeeklySummaryCard() { const [error, setError] = useState(null); const [isCollapsed, setIsCollapsed] = useState(false); -const maxCommits = summary?.commits - ? Math.max(summary.commits.current, summary.commits.previous, 1) - : 1; + const maxCommits = summary?.commits + ? Math.max(summary.commits.current, summary.commits.previous, 1) + : 1; -const maxPRs = summary?.prs - ? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1) - : 1; + const maxPRs = summary?.prs + ? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1) + : 1; -const maxActiveDays = summary?.activeDays - ? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1) - : 1; + const maxActiveDays = summary?.activeDays + ? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1) + : 1; const fetchSummary = useCallback(() => { setLoading(true); @@ -106,7 +106,7 @@ const maxActiveDays = summary?.activeDays
{error}
- ) : summary ? ( + ) : (summary && summary.commits && summary.prs && summary.activeDays) ? (
{/* Commits Comparison */}
diff --git a/test/AIMentorWidget.test.ts b/test/AIMentorWidget.test.ts new file mode 100644 index 00000000..2c393fa2 --- /dev/null +++ b/test/AIMentorWidget.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AIMentorWidget } from "@/components/AIMentorWidget"; +import React from "react"; + +const mockAIData = { + data: { + insights: [ + { + id: "insight-1", + type: "consistency", + title: "High Consistency", + description: "You coded 5 days in a row!", + severity: "positive", + }, + ], + trend: { direction: "up", percentage: 25 }, + aiSummary: "

Weekly Focus

Keep up the great work in devtrack!

", + generatedAt: "2026-06-01T07:00:00.000Z", + }, +}; + +describe("AIMentorWidget Component", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAIData), + } as Response) + ) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders loader initially and then renders insights and trend data", async () => { + render(React.createElement(AIMentorWidget)); + + // Loader status should exist initially + expect(screen.getByRole("status")).toBeInTheDocument(); + + await waitFor(() => { + // Heading AI Mentor + expect(screen.getByText("AI Mentor")).toBeInTheDocument(); + // Trend text + expect(screen.getByText(/↑/i)).toBeInTheDocument(); + expect(screen.getByText(/25%/i)).toBeInTheDocument(); + // Insight card + expect(screen.getByText("High Consistency")).toBeInTheDocument(); + expect(screen.getByText("You coded 5 days in a row!")).toBeInTheDocument(); + }); + }); + + it("renders safe HTML formatting post-sanitization", async () => { + render(React.createElement(AIMentorWidget)); + + await waitFor(() => { + // 1. Check for header element

+ const heading = screen.getByRole("heading", { level: 3 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toBe("Weekly Focus"); + + // 2. Check for strong element + const strongElement = screen.getByText("great work"); + expect(strongElement.tagName).toBe("STRONG"); + + // 3. Check for code element + const codeElement = screen.getByText("devtrack"); + expect(codeElement.tagName).toBe("CODE"); + }); + }); + + it("sanitizes and strips malicious script tags and event handlers from HTML", async () => { + const maliciousData = { + data: { + ...mockAIData.data, + aiSummary: `

Safe Advice

Safe text inside strong`, + }, + }; + + vi.mocked(global.fetch).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(maliciousData), + } as Response) + ); + + render(React.createElement(AIMentorWidget)); + + await waitFor(() => { + // The safe elements should still be rendered correctly + expect(screen.getByRole("heading", { level: 3 }).textContent).toBe("Safe Advice"); + expect(screen.getByText("Safe text inside strong")).toBeInTheDocument(); + + // The script tag should be stripped from the DOM + const aiSummaryContainer = screen.getByText("Safe Advice").parentElement; + expect(aiSummaryContainer?.innerHTML).not.toContain("