diff --git a/web/netlify/functions/__tests__/analytics-accm-aggregation.test.ts b/web/netlify/functions/__tests__/analytics-accm-aggregation.test.ts new file mode 100644 index 0000000000..b1f8437eb5 --- /dev/null +++ b/web/netlify/functions/__tests__/analytics-accm-aggregation.test.ts @@ -0,0 +1,237 @@ +// @vitest-environment node +/** + * Unit tests for analytics-accm/aggregation.ts pure aggregation functions. + * + * Run: cd web && npx vitest run netlify/functions/__tests__/analytics-accm-aggregation.test.ts + */ +import { describe, expect, it } from "vitest"; +import { + aggregateWeeklyActivity, + aggregateCIPassRates, + aggregateContributorGrowth, +} from "../analytics-accm/aggregation"; +import type { PRItem, IssueItem, WorkflowRunItem } from "../analytics-accm/fetchers"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const WEEK_25 = "2026-W25"; +const WEEK_26 = "2026-W26"; + +function makePR(overrides: Partial = {}): PRItem { + return { + created_at: "2026-06-15T10:00:00Z", // W25 + merged_at: null, + user: { login: "dev1" }, + labels: [], + ...overrides, + }; +} + +function makeIssue(overrides: Partial = {}): IssueItem { + return { + created_at: "2026-06-15T10:00:00Z", // W25 + closed_at: null, + user: { login: "dev1" }, + labels: [], + ...overrides, + }; +} + +function makeWorkflowRun(overrides: Partial = {}): WorkflowRunItem { + return { + created_at: "2026-06-15T10:00:00Z", + conclusion: "success", + status: "completed", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// aggregateWeeklyActivity +// --------------------------------------------------------------------------- + +describe("aggregateWeeklyActivity", () => { + it("returns empty buckets when no PRs or issues exist", () => { + const result = aggregateWeeklyActivity([], [], [WEEK_25, WEEK_26]); + expect(result).toHaveLength(2); + expect(result[0].prsOpened).toBe(0); + expect(result[0].issuesOpened).toBe(0); + expect(result[0].uniqueContributors).toBe(0); + }); + + it("counts PRs opened in the correct week bucket", () => { + const prs = [makePR(), makePR({ user: { login: "dev2" } })]; + const result = aggregateWeeklyActivity(prs, [], [WEEK_25]); + expect(result[0].prsOpened).toBe(2); + }); + + it("counts merged PRs in the merge week (not creation week)", () => { + const prs = [ + makePR({ + created_at: "2026-06-15T10:00:00Z", // W25 + merged_at: "2026-06-22T10:00:00Z", // W26 + }), + ]; + const result = aggregateWeeklyActivity(prs, [], [WEEK_25, WEEK_26]); + expect(result[0].prsOpened).toBe(1); + expect(result[0].prsMerged).toBe(0); + expect(result[1].prsMerged).toBe(1); + }); + + it("classifies AI vs human PRs", () => { + const prs = [ + makePR({ user: { login: "Copilot" } }), // AI author + makePR({ user: { login: "human-dev" } }), // Human + makePR({ labels: [{ name: "ai-generated" }], user: { login: "someone" } }), // AI label + ]; + const result = aggregateWeeklyActivity(prs, [], [WEEK_25]); + expect(result[0].aiPrs).toBe(2); + expect(result[0].humanPrs).toBe(1); + }); + + it("counts issues opened and closed in correct week buckets", () => { + const issues = [ + makeIssue({ + created_at: "2026-06-15T10:00:00Z", + closed_at: "2026-06-22T10:00:00Z", + }), + ]; + const result = aggregateWeeklyActivity([], issues, [WEEK_25, WEEK_26]); + expect(result[0].issuesOpened).toBe(1); + expect(result[0].issuesClosed).toBe(0); + expect(result[1].issuesClosed).toBe(1); + }); + + it("tracks unique contributors per week", () => { + const prs = [ + makePR({ user: { login: "dev1" } }), + makePR({ user: { login: "dev1" } }), // duplicate + makePR({ user: { login: "dev2" } }), + ]; + const result = aggregateWeeklyActivity(prs, [], [WEEK_25]); + expect(result[0].uniqueContributors).toBe(2); + }); + + it("ignores items that fall outside provided weeks", () => { + const prs = [makePR({ created_at: "2020-01-01T00:00:00Z" })]; + const result = aggregateWeeklyActivity(prs, [], [WEEK_25]); + expect(result[0].prsOpened).toBe(0); + }); + + it("preserves week order from input", () => { + const result = aggregateWeeklyActivity([], [], [WEEK_26, WEEK_25]); + expect(result[0].week).toBe(WEEK_26); + expect(result[1].week).toBe(WEEK_25); + }); +}); + +// --------------------------------------------------------------------------- +// aggregateCIPassRates +// --------------------------------------------------------------------------- + +describe("aggregateCIPassRates", () => { + it("returns zero stats when no runs exist", () => { + const result = aggregateCIPassRates([], [], [WEEK_25]); + expect(result[0].coverage).toEqual({ total: 0, passed: 0, rate: 0 }); + expect(result[0].nightly).toEqual({ total: 0, passed: 0, rate: 0 }); + }); + + it("calculates pass rate correctly", () => { + const runs = [ + makeWorkflowRun({ conclusion: "success" }), + makeWorkflowRun({ conclusion: "success" }), + makeWorkflowRun({ conclusion: "failure" }), + ]; + const result = aggregateCIPassRates(runs, [], [WEEK_25]); + expect(result[0].coverage.total).toBe(3); + expect(result[0].coverage.passed).toBe(2); + expect(result[0].coverage.rate).toBeCloseTo(66.7, 0); + }); + + it("100% pass rate for all-success runs", () => { + const runs = [ + makeWorkflowRun({ conclusion: "success" }), + makeWorkflowRun({ conclusion: "success" }), + ]; + const result = aggregateCIPassRates(runs, [], [WEEK_25]); + expect(result[0].coverage.rate).toBe(100); + }); + + it("separates coverage and nightly runs", () => { + const coverageRuns = [makeWorkflowRun({ conclusion: "success" })]; + const nightlyRuns = [makeWorkflowRun({ conclusion: "failure" })]; + const result = aggregateCIPassRates(coverageRuns, nightlyRuns, [WEEK_25]); + expect(result[0].coverage.passed).toBe(1); + expect(result[0].nightly.passed).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// aggregateContributorGrowth +// --------------------------------------------------------------------------- + +describe("aggregateContributorGrowth", () => { + it("returns zero growth when no contributions exist", () => { + const result = aggregateContributorGrowth([], [], [WEEK_25]); + expect(result.total).toBe(0); + expect(result.weekly).toHaveLength(1); + expect(result.weekly[0].newContributors).toBe(0); + expect(result.weekly[0].totalToDate).toBe(0); + }); + + it("counts total unique contributors across PRs and issues", () => { + const prs = [makePR({ user: { login: "dev1" } })]; + const issues = [ + makeIssue({ user: { login: "dev2" } }), + makeIssue({ user: { login: "dev1" } }), // duplicate + ]; + const result = aggregateContributorGrowth(prs, issues, [WEEK_25]); + expect(result.total).toBe(2); + }); + + it("tracks new contributors per week", () => { + const prs = [ + makePR({ created_at: "2026-06-15T10:00:00Z", user: { login: "dev1" } }), // W25 + makePR({ created_at: "2026-06-22T10:00:00Z", user: { login: "dev2" } }), // W26 + ]; + const result = aggregateContributorGrowth(prs, [], [WEEK_25, WEEK_26]); + expect(result.weekly[0].newContributors).toBe(1); + expect(result.weekly[1].newContributors).toBe(1); + }); + + it("accumulates totalToDate across weeks", () => { + const prs = [ + makePR({ created_at: "2026-06-15T10:00:00Z", user: { login: "dev1" } }), + makePR({ created_at: "2026-06-22T10:00:00Z", user: { login: "dev2" } }), + ]; + const result = aggregateContributorGrowth(prs, [], [WEEK_25, WEEK_26]); + expect(result.weekly[0].totalToDate).toBe(1); + expect(result.weekly[1].totalToDate).toBe(2); + }); + + it("attributes contributors to their earliest activity week", () => { + const prs = [ + makePR({ created_at: "2026-06-22T10:00:00Z", user: { login: "dev1" } }), // W26 + ]; + const issues = [ + makeIssue({ created_at: "2026-06-15T10:00:00Z", user: { login: "dev1" } }), // W25 (earlier) + ]; + const result = aggregateContributorGrowth(prs, issues, [WEEK_25, WEEK_26]); + // dev1 should appear in W25, not W26 + expect(result.weekly[0].newContributors).toBe(1); + expect(result.weekly[1].newContributors).toBe(0); + }); + + it("counts pre-window contributors in running total", () => { + // dev1 contributed before our window + const prs = [ + makePR({ created_at: "2020-01-06T10:00:00Z", user: { login: "old-dev" } }), + makePR({ created_at: "2026-06-15T10:00:00Z", user: { login: "new-dev" } }), + ]; + const result = aggregateContributorGrowth(prs, [], [WEEK_25]); + // old-dev should be counted in pre-window total + expect(result.weekly[0].totalToDate).toBe(2); // 1 pre-window + 1 new + }); +}); diff --git a/web/netlify/functions/__tests__/analytics-accm-helpers.test.ts b/web/netlify/functions/__tests__/analytics-accm-helpers.test.ts new file mode 100644 index 0000000000..476032798e --- /dev/null +++ b/web/netlify/functions/__tests__/analytics-accm-helpers.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +/** + * Unit tests for analytics-accm/helpers.ts pure utility functions. + * + * Run: cd web && npx vitest run netlify/functions/__tests__/analytics-accm-helpers.test.ts + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isoWeek, + lastNWeeks, + weeksSinceProjectStart, + daysSinceProjectStart, + isAIContribution, + AI_AUTHORS, + AI_LABEL, + PROJECT_START_DATE, + MAX_WEEKS_OF_HISTORY, +} from "../analytics-accm/helpers"; + +describe("isoWeek", () => { + it("returns correct ISO week for a mid-year date", () => { + // 2026-06-15 (Monday) → W25 + expect(isoWeek(new Date("2026-06-15T12:00:00Z"))).toBe("2026-W25"); + }); + + it("returns correct ISO week for January", () => { + // 2026-01-12 (Monday) → W03 + expect(isoWeek(new Date("2026-01-12T12:00:00Z"))).toBe("2026-W03"); + }); + + it("handles year boundary (Jan 1)", () => { + // 2026-01-01 is a Thursday + const result = isoWeek(new Date("2026-01-01T12:00:00Z")); + expect(result).toMatch(/^\d{4}-W01$/); + }); + + it("handles last day of year", () => { + // 2025-12-31 is a Wednesday → W01 of 2026 (ISO week rule) + const result = isoWeek(new Date("2025-12-31")); + expect(result).toMatch(/^\d{4}-W\d{2}$/); + }); + + it("returns correctly formatted string", () => { + const result = isoWeek(new Date("2026-06-15")); + expect(result).toMatch(/^\d{4}-W\d{2}$/); + }); +}); + +describe("lastNWeeks", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-25T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 1 week when n=1", () => { + const weeks = lastNWeeks(1); + expect(weeks).toHaveLength(1); + expect(weeks[0]).toMatch(/^\d{4}-W\d{2}$/); + }); + + it("returns unique weeks in chronological order", () => { + const weeks = lastNWeeks(4); + expect(weeks.length).toBeGreaterThanOrEqual(1); + expect(weeks.length).toBeLessThanOrEqual(4); + // Each entry is unique + expect(new Set(weeks).size).toBe(weeks.length); + }); + + it("ends with the current week", () => { + const weeks = lastNWeeks(4); + const currentWeek = isoWeek(new Date()); + expect(weeks[weeks.length - 1]).toBe(currentWeek); + }); + + it("returns empty-safe for n=0", () => { + const weeks = lastNWeeks(0); + expect(weeks).toHaveLength(0); + }); +}); + +describe("weeksSinceProjectStart", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns at least 1", () => { + // Set time to project start date + vi.setSystemTime(new Date(PROJECT_START_DATE)); + expect(weeksSinceProjectStart()).toBeGreaterThanOrEqual(1); + }); + + it("grows over time", () => { + vi.setSystemTime(new Date("2026-02-16")); + const early = weeksSinceProjectStart(); + vi.setSystemTime(new Date("2026-06-16")); + const later = weeksSinceProjectStart(); + expect(later).toBeGreaterThan(early); + }); + + it("is capped at MAX_WEEKS_OF_HISTORY", () => { + // Set time far in the future + vi.setSystemTime(new Date("2040-01-01")); + expect(weeksSinceProjectStart()).toBeLessThanOrEqual(MAX_WEEKS_OF_HISTORY); + }); +}); + +describe("daysSinceProjectStart", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns at least 1 on project start date", () => { + vi.setSystemTime(new Date(PROJECT_START_DATE)); + expect(daysSinceProjectStart()).toBeGreaterThanOrEqual(1); + }); + + it("returns correct number of days", () => { + vi.setSystemTime(new Date("2026-01-26")); // 10 days after 2026-01-16 + const days = daysSinceProjectStart(); + expect(days).toBe(10); + }); +}); + +describe("isAIContribution", () => { + it("returns true for known AI authors", () => { + for (const author of AI_AUTHORS) { + expect(isAIContribution([], author)).toBe(true); + } + }); + + it("returns true for any bot author (ending with [bot])", () => { + expect(isAIContribution([], "dependabot[bot]")).toBe(true); + expect(isAIContribution([], "renovate[bot]")).toBe(true); + }); + + it("returns true when labels include AI_LABEL", () => { + expect(isAIContribution([{ name: AI_LABEL }], "human-dev")).toBe(true); + }); + + it("returns false for human authors without AI label", () => { + expect(isAIContribution([], "human-dev")).toBe(false); + expect(isAIContribution([{ name: "bug" }], "human-dev")).toBe(false); + }); + + it("handles empty labels array", () => { + expect(isAIContribution([], "human-dev")).toBe(false); + }); + + it("handles null-ish labels gracefully", () => { + // The function guards with (labels || []) + expect(isAIContribution(null as unknown as { name: string }[], "human-dev")).toBe(false); + }); +}); diff --git a/web/netlify/functions/__tests__/github-pipelines-helpers.test.ts b/web/netlify/functions/__tests__/github-pipelines-helpers.test.ts new file mode 100644 index 0000000000..fc832ae90f --- /dev/null +++ b/web/netlify/functions/__tests__/github-pipelines-helpers.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +/** + * Unit tests for github-pipelines/helpers.ts pure utility functions. + * + * Run: cd web && npx vitest run netlify/functions/__tests__/github-pipelines-helpers.test.ts + */ +import { describe, expect, it, vi } from "vitest"; +import { jsonResponse, isValidRepo, isAllowedRepo } from "../github-pipelines/helpers"; + +describe("jsonResponse", () => { + it("returns a Response with JSON content-type", () => { + const res = jsonResponse({ ok: true }); + expect(res.headers.get("Content-Type")).toBe("application/json"); + }); + + it("defaults to status 200", () => { + const res = jsonResponse({ ok: true }); + expect(res.status).toBe(200); + }); + + it("accepts custom status", () => { + const res = jsonResponse({ error: "not found" }, { status: 404 }); + expect(res.status).toBe(404); + }); + + it("serializes body as JSON", async () => { + const data = { items: [1, 2, 3], nested: { a: "b" } }; + const res = jsonResponse(data); + const parsed = await res.json(); + expect(parsed).toEqual(data); + }); + + it("merges custom headers with Content-Type", () => { + const res = jsonResponse({}, { headers: { "X-Custom": "test" } }); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.headers.get("X-Custom")).toBe("test"); + }); + + it("handles null body", async () => { + const res = jsonResponse(null); + const text = await res.text(); + expect(text).toBe("null"); + }); +}); + +describe("isValidRepo", () => { + it("accepts owner/repo format", () => { + expect(isValidRepo("kubestellar/console")).toBe(true); + }); + + it("accepts repos with dots, hyphens, underscores", () => { + expect(isValidRepo("my-org/my_repo.v2")).toBe(true); + }); + + it("rejects null", () => { + expect(isValidRepo(null)).toBe(false); + }); + + it("rejects empty string", () => { + expect(isValidRepo("")).toBe(false); + }); + + it("rejects bare name without slash", () => { + expect(isValidRepo("console")).toBe(false); + }); + + it("rejects paths with multiple slashes", () => { + expect(isValidRepo("org/repo/extra")).toBe(false); + }); + + it("rejects special characters that could cause injection", () => { + expect(isValidRepo("org/repo;rm -rf")).toBe(false); + expect(isValidRepo("org/repo$(whoami)")).toBe(false); + }); +}); + +describe("isAllowedRepo", () => { + it("rejects invalid repo format", () => { + expect(isAllowedRepo(null)).toBe(false); + expect(isAllowedRepo("")).toBe(false); + expect(isAllowedRepo("no-slash")).toBe(false); + }); + + it("rejects valid-format repos not in allowlist", () => { + expect(isAllowedRepo("evil-org/evil-repo")).toBe(false); + }); + + it("is case-insensitive", () => { + // Test that case doesn't matter for allowed repos + const result1 = isAllowedRepo("kubestellar/console"); + const result2 = isAllowedRepo("KubeStellar/Console"); + expect(result1).toBe(result2); + }); +}); diff --git a/web/src/lib/utils/__tests__/layouts.test.ts b/web/src/lib/utils/__tests__/layouts.test.ts new file mode 100644 index 0000000000..d1392ca448 --- /dev/null +++ b/web/src/lib/utils/__tests__/layouts.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for layout utility functions. + * + * Run: cd web && npx vitest run src/lib/utils/__tests__/layouts.test.ts + */ +import { describe, expect, it } from "vitest"; +import { + flexCenter, + flexStart, + flexWrapBetween, + flexCenterJustify, + flexCol, + flexColCenter, + LAYOUTS, +} from "../layouts"; + +describe("flexCenter", () => { + it("defaults to gap-2", () => { + expect(flexCenter()).toBe("flex items-center gap-2"); + }); + + it("accepts custom numeric gap", () => { + expect(flexCenter(1)).toBe("flex items-center gap-1"); + expect(flexCenter(4)).toBe("flex items-center gap-4"); + }); + + it("accepts string gap", () => { + expect(flexCenter("0.5")).toBe("flex items-center gap-0.5"); + }); +}); + +describe("flexStart", () => { + it("defaults to gap-2", () => { + expect(flexStart()).toBe("flex items-start gap-2"); + }); + + it("accepts custom gap", () => { + expect(flexStart(3)).toBe("flex items-start gap-3"); + }); +}); + +describe("flexWrapBetween", () => { + it("defaults to gap-2", () => { + expect(flexWrapBetween()).toBe("flex flex-wrap items-center justify-between gap-2"); + }); + + it("accepts custom gap", () => { + expect(flexWrapBetween(4)).toBe("flex flex-wrap items-center justify-between gap-4"); + }); +}); + +describe("flexCenterJustify", () => { + it("defaults to gap-1", () => { + expect(flexCenterJustify()).toBe("flex items-center justify-center gap-1"); + }); +}); + +describe("flexCol", () => { + it("defaults to gap-4", () => { + expect(flexCol()).toBe("flex flex-col gap-4"); + }); + + it("accepts custom gap", () => { + expect(flexCol(2)).toBe("flex flex-col gap-2"); + }); +}); + +describe("flexColCenter", () => { + it("defaults to gap-2", () => { + expect(flexColCenter()).toBe( + "flex flex-col items-center justify-center min-h-card text-muted-foreground gap-2" + ); + }); + + it("accepts custom gap", () => { + expect(flexColCenter(4)).toContain("gap-4"); + }); +}); + +describe("LAYOUTS constants", () => { + it("matches expected values for most common patterns", () => { + expect(LAYOUTS.CENTER_GAP_2).toBe("flex items-center gap-2"); + expect(LAYOUTS.CENTER_GAP_1).toBe("flex items-center gap-1"); + expect(LAYOUTS.CENTER_GAP_3).toBe("flex items-center gap-3"); + expect(LAYOUTS.START_GAP_2).toBe("flex items-start gap-2"); + }); + + it("WRAP_BETWEEN_GAP_2 matches flexWrapBetween() default", () => { + expect(LAYOUTS.WRAP_BETWEEN_GAP_2).toBe(flexWrapBetween()); + }); + + it("CENTER_JUSTIFY_GAP_1 matches flexCenterJustify() default", () => { + expect(LAYOUTS.CENTER_JUSTIFY_GAP_1).toBe(flexCenterJustify()); + }); + + it("COL_CENTER_EMPTY matches flexColCenter() default", () => { + expect(LAYOUTS.COL_CENTER_EMPTY).toBe(flexColCenter()); + }); +});