diff --git a/package-lock.json b/package-lock.json index 947e04e..f3f3df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "jest-environment-jsdom": "^30.4.1", "tailwindcss": "^4", "ts-jest": "^29.4.11", + "ts-node": "^10.9.2", "typescript": "^5" } }, @@ -601,6 +602,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2826,6 +2851,34 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -3745,6 +3798,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3828,6 +3894,13 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4195,9 +4268,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.41", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", - "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4231,9 +4304,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -4251,11 +4324,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -4389,9 +4462,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -4653,6 +4726,13 @@ "dev": true, "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4917,6 +4997,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4961,9 +5051,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.387", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", - "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -9876,9 +9966,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -11946,6 +12036,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12296,9 +12430,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -12336,6 +12470,13 @@ "punycode": "^2.1.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -12798,6 +12939,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 521ef6a..caa501f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "jest-environment-jsdom": "^30.4.1", "tailwindcss": "^4", "ts-jest": "^29.4.11", + "ts-node": "^10.9.2", "typescript": "^5" }, "overrides": { diff --git a/src/app/auth/callback/CallbackClient.test.tsx b/src/app/auth/callback/CallbackClient.test.tsx index ac5d12a..17fec29 100644 --- a/src/app/auth/callback/CallbackClient.test.tsx +++ b/src/app/auth/callback/CallbackClient.test.tsx @@ -1,13 +1,13 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { CallbackClient } from "./CallbackClient"; import { useAuth } from "@/context/AuthContext"; import type { AuthUser } from "@/types"; jest.mock("next/navigation", () => ({ useSearchParams: jest.fn(), - useRouter: () => ({ replace: jest.fn() }), + useRouter: jest.fn(() => ({ replace: jest.fn() })), })); jest.mock("@/context/AuthContext", () => ({ @@ -20,6 +20,7 @@ const mockReplace = jest.fn(); const mockedUseAuth = useAuth as jest.MockedFunction; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockedUseSearchParams = useSearchParams as jest.MockedFunction; +const mockedUseRouter = useRouter as jest.Mock; function makeUser(roles: string[]): AuthUser { return { @@ -35,8 +36,7 @@ function makeUser(roles: string[]): AuthUser { describe("CallbackClient — role-based redirect (issue #77)", () => { beforeEach(() => { jest.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (require("next/navigation").useRouter = () => ({ replace: mockReplace })); + mockedUseRouter.mockReturnValue({ replace: mockReplace }); mockedUseSearchParams.mockReturnValue(new URLSearchParams({ token: "jwt-token" })); }); diff --git a/src/components/bounty/BountyCard.test.tsx b/src/components/bounty/BountyCard.test.tsx index e6fc0f9..dfd9cad 100644 --- a/src/components/bounty/BountyCard.test.tsx +++ b/src/components/bounty/BountyCard.test.tsx @@ -66,10 +66,14 @@ describe("BountyCard — description preview", () => { }); describe("BountyCard — deadline countdown (#216)", () => { - it('shows "Xd left" for a future deadline', () => { + it('shows "X days left" for a future deadline', () => { + // formatDaysUntil (src/lib/utils.ts) is the actual, deliberate + // implementation this renders through — properly pluralized ("1 day + // left" vs "N days left"), not the abbreviated "Xd left" this test + // previously (and incorrectly) expected. const deadline = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(); render(); - expect(screen.getByText("5d left")).toBeInTheDocument(); + expect(screen.getByText("5 days left")).toBeInTheDocument(); }); it('shows "Deadline passed" for a past deadline', () => { diff --git a/src/components/bounty/BountyStatus.tsx b/src/components/bounty/BountyStatus.tsx index 5a82f69..d48237e 100644 --- a/src/components/bounty/BountyStatus.tsx +++ b/src/components/bounty/BountyStatus.tsx @@ -13,18 +13,24 @@ interface BountyStatusProps { const statusColors: Record = { open: 'text-green-600 bg-green-50 border-green-200', - 'in-progress': 'text-yellow-600 bg-yellow-50 border-yellow-200', + funded: 'text-teal-600 bg-teal-50 border-teal-200', claimed: 'text-blue-600 bg-blue-50 border-blue-200', - completed: 'text-purple-600 bg-purple-50 border-purple-200', - cancelled: 'text-red-600 bg-red-50 border-red-200', + in_review: 'text-yellow-600 bg-yellow-50 border-yellow-200', + merged: 'text-purple-600 bg-purple-50 border-purple-200', + paid: 'text-purple-600 bg-purple-50 border-purple-200', + refunded: 'text-red-600 bg-red-50 border-red-200', + expired: 'text-red-600 bg-red-50 border-red-200', }; const statusLabels: Record = { open: 'Open', - 'in-progress': 'In Progress', + funded: 'Funded', claimed: 'Claimed', - completed: 'Completed', - cancelled: 'Cancelled', + in_review: 'In Review', + merged: 'Merged', + paid: 'Paid', + refunded: 'Refunded', + expired: 'Expired', }; export function BountyStatus({ @@ -109,10 +115,6 @@ export function BountyStatus({ Claimed by: {bounty.claimedBy} )} - -
- Updated: {new Date(bounty.updatedAt).toLocaleTimeString()} -
); } diff --git a/src/components/bounty/ClaimButton.tsx b/src/components/bounty/ClaimButton.tsx index 161d464..194b0b8 100644 --- a/src/components/bounty/ClaimButton.tsx +++ b/src/components/bounty/ClaimButton.tsx @@ -103,9 +103,9 @@ export function ClaimButton({ await claim(); }; - const isDisabled = isClaiming || status === 'claimed' || status === 'completed'; + const isDisabled = isClaiming || status === 'claimed'; - if (status === 'claimed' || status === 'completed') { + if (status === 'claimed') { return (

diff --git a/src/components/dashboard/DashboardShell.test.tsx b/src/components/dashboard/DashboardShell.test.tsx index 6cd5f93..0042a9c 100644 --- a/src/components/dashboard/DashboardShell.test.tsx +++ b/src/components/dashboard/DashboardShell.test.tsx @@ -7,27 +7,39 @@ * and the role-switcher section rendering the correct active role. */ +import React from "react"; import { render, screen } from "@testing-library/react"; +import { usePathname } from "next/navigation"; import { DashboardShell } from "./DashboardShell"; jest.mock("next/navigation", () => ({ usePathname: jest.fn(), })); -const { usePathname } = require("next/navigation"); +const mockedUsePathname = usePathname as jest.Mock; -// Stub next/link to render a plain so queries work in jsdom +// Stub next/link to render a plain so queries work in jsdom. +// `require("react")` (rather than a top-level import reference) is +// necessary here: jest.mock() factories are hoisted above imports and may +// only reference out-of-scope variables prefixed "mock", so this can't be +// rewritten as a normal ES import without breaking that hoisting contract. jest.mock("next/link", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react"); - return React.forwardRef(function Link({ href, children, ...rest }: any, ref: any) { + return React.forwardRef(function Link( + { href, children, ...rest }: { href: string; children?: React.ReactNode } & Record, + ref: React.Ref, + ) { return {children}; }); }); -function shell(role: string, pathname: string) { - usePathname.mockReturnValue(pathname); +type Role = "contributor" | "maintainer" | "sponsor"; + +function shell(role: Role, pathname: string) { + mockedUsePathname.mockReturnValue(pathname); return render( - +

child content

, ); @@ -124,7 +136,7 @@ describe("DashboardShell — content rendering", () => { }); it("renders the subtitle when provided", () => { - usePathname.mockReturnValue("/dashboard/contributor"); + mockedUsePathname.mockReturnValue("/dashboard/contributor"); render(

child

diff --git a/src/components/layout/Navbar.test.tsx b/src/components/layout/Navbar.test.tsx index 88437ab..5f5bba6 100644 --- a/src/components/layout/Navbar.test.tsx +++ b/src/components/layout/Navbar.test.tsx @@ -8,7 +8,9 @@ */ import { render, screen } from "@testing-library/react"; +import { useAuth } from "@/context/AuthContext"; import { Navbar } from "./Navbar"; +import type { AuthUser } from "@/types"; jest.mock("@/context/AuthContext", () => ({ useAuth: jest.fn(), @@ -32,10 +34,10 @@ jest.mock("@/components/ui/Button", () => ({ ), })); -const { useAuth } = require("@/context/AuthContext"); +const mockedUseAuth = useAuth as jest.Mock; -function mockAuth(overrides: Partial<{ user: any; loading: boolean; logout: jest.fn }>) { - useAuth.mockReturnValue({ +function mockAuth(overrides: Partial<{ user: AuthUser | null; loading: boolean; logout: () => void }>) { + mockedUseAuth.mockReturnValue({ user: null, loading: false, logout: jest.fn(), @@ -75,7 +77,7 @@ describe("Navbar — signed out", () => { }); describe("Navbar — signed in", () => { - const fakeUser = { + const fakeUser: AuthUser = { id: "u1", username: "octocat", displayName: "The Octocat", diff --git a/src/components/ui/Tabs.test.tsx b/src/components/ui/Tabs.test.tsx index e11fd36..c9f44cd 100644 --- a/src/components/ui/Tabs.test.tsx +++ b/src/components/ui/Tabs.test.tsx @@ -24,7 +24,7 @@ describe("Tabs — onChange", () => { const onChange = jest.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: "Completed" })); + fireEvent.click(screen.getByRole("tab", { name: "Completed" })); expect(onChange).toHaveBeenCalledWith("completed"); }); @@ -32,7 +32,7 @@ describe("Tabs — onChange", () => { const onChange = jest.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: "Active" })); + fireEvent.click(screen.getByRole("tab", { name: "Active" })); // onChange still fires — the parent decides whether to ignore duplicates expect(onChange).toHaveBeenCalledWith("active"); }); @@ -41,22 +41,22 @@ describe("Tabs — onChange", () => { describe("Tabs — active styling", () => { it("applies bg-white and shadow-sm to the active tab", () => { render( {}} />); - const activeBtn = screen.getByRole("button", { name: "Active" }); + const activeBtn = screen.getByRole("tab", { name: "Active" }); expect(activeBtn.className).toContain("bg-white"); expect(activeBtn.className).toContain("shadow-sm"); }); it("applies muted text styling to inactive tabs", () => { render( {}} />); - const inactiveBtn = screen.getByRole("button", { name: "Completed" }); + const inactiveBtn = screen.getByRole("tab", { name: "Completed" }); expect(inactiveBtn.className).toContain("text-slate-500"); expect(inactiveBtn.className).not.toContain("bg-white"); }); it("switches active styling when a different tab becomes active", () => { render( {}} />); - const completedBtn = screen.getByRole("button", { name: "Completed" }); - const activeBtn = screen.getByRole("button", { name: "Active" }); + const completedBtn = screen.getByRole("tab", { name: "Completed" }); + const activeBtn = screen.getByRole("tab", { name: "Active" }); expect(completedBtn.className).toContain("bg-white"); expect(activeBtn.className).toContain("text-slate-500"); }); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 01b2e39..ab39a67 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -51,6 +51,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) { clearToken(); setUser(null); + setLoading(false); return; } if (attempt < MAX_RETRIES - 1) { diff --git a/src/context/WalletContext.test.tsx b/src/context/WalletContext.test.tsx index 04a9e45..f0d4422 100644 --- a/src/context/WalletContext.test.tsx +++ b/src/context/WalletContext.test.tsx @@ -13,6 +13,14 @@ jest.mock("@/context/AuthContext", () => ({ jest.mock("@/lib/wallet", () => ({ connectWallet: jest.fn(), + // Added for #71/#2 (reconciling a restored address/network against + // Freighter's live state on mount) — this mock predated both and was + // missing them entirely, so any test path that reaches the deferred + // mount-hydration effect threw `is not a function`. Default to "nothing + // to reconcile" (no live address, no network mismatch); tests that care + // about the mismatch paths specifically override these. + getActiveFreighterAddress: jest.fn().mockResolvedValue(null), + checkNetworkMismatch: jest.fn().mockResolvedValue(null), })); // WalletContext imports apiRequest for profile linking on connect(), which diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 4a1e8a3..c9e3c0e 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -102,17 +102,26 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { }, []); useCrossTabStorage(WALLET_KEY, handleWalletKeyChangedElsewhere); + // Tracks the latest `address` for the logout-clearing effect below + // without making that effect depend on (and therefore re-run on) every + // address change — only an actual `user` transition should trigger a + // clear. Kept current after every render rather than read via a + // `setAddress(prev => ...)` functional updater, which is exactly the + // "peek at previous state inside an effect" shape + // react-hooks/set-state-in-effect flags. + const addressRef = useRef(address); + useEffect(() => { + addressRef.current = address; + }); + // #270: When AuthContext logs the user out (cross-tab or otherwise), // clear the wallet connection too so a stale address is never usable // in a tab where the session has ended. useEffect(() => { - if (user === null) { - setAddress((prev) => { - if (prev === null) return prev; - window.localStorage.removeItem(WALLET_KEY); - setNetwork(null); - return null; - }); + if (user === null && addressRef.current !== null) { + window.localStorage.removeItem(WALLET_KEY); + setNetwork(null); + setAddress(null); } }, [user]); diff --git a/src/hooks/useBountyStatus.ts b/src/hooks/useBountyStatus.ts index b2099b2..8d28d34 100644 --- a/src/hooks/useBountyStatus.ts +++ b/src/hooks/useBountyStatus.ts @@ -35,7 +35,7 @@ export function useBountyStatus({ refetch, isPolling, isBackingOff, - } = useSmartPolling<{ data: Bounty; source: 'live' | 'mock' }>({ + } = useSmartPolling<{ data: Bounty | undefined; source: 'live' | 'mock' }>({ fetchFn: async () => { const result = await fetchBounty(bountyId, fallbackBounty); return result; diff --git a/src/types/bounty.ts b/src/types/bounty.ts index e9e6239..20859fa 100644 --- a/src/types/bounty.ts +++ b/src/types/bounty.ts @@ -1,3 +1,5 @@ +import type { Difficulty, TeamSplit } from './shared'; + export type BountyStatus = | 'open' | 'funded' @@ -14,7 +16,7 @@ export interface Bounty { description: string; reward: number; asset: "USDC" | "XLM"; - difficulty: string; + difficulty: Difficulty; status: BountyStatus; org: string; repo: string; @@ -25,7 +27,7 @@ export interface Bounty { claimedById?: string; milestoneId?: string; escrowId?: string; - teamSplits?: { role: string; percentage: number; contributor?: string }[]; + teamSplits?: TeamSplit[]; teamSplitsValid?: { valid: boolean; sum: number; message?: string }; } diff --git a/src/types/index.ts b/src/types/index.ts index a14a265..80d05f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ export * from './bounty'; +export * from './shared'; diff --git a/src/types/shared.ts b/src/types/shared.ts new file mode 100644 index 0000000..0ed6570 --- /dev/null +++ b/src/types/shared.ts @@ -0,0 +1,50 @@ +export type UserRole = "contributor" | "maintainer" | "sponsor"; + +// Mirrors mergefi-backend's BountyDifficulty enum +export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert"; + +export interface TeamSplit { + role: string; + percentage: number; + contributor?: string; +} + +export interface Milestone { + id: string; + name: string; + repo: string; + budget: number; + distributed: number; + asset: "USDC" | "XLM"; + issueCount: number; + completedCount: number; +} + +export interface ReputationProfile { + handle: string; + avatarUrl: string; + lifetimeEarnings: number; + mergedPRs: number; + completionRate: number; + avgReviewTimeHours: number; + onTimeDeliveryRate: number; + languages: string[]; + organizations: string[]; +} + +export interface MaintenancePool { + id: string; + repo: string; + monthlyDeposit: number; + balance: number; + asset: "USDC" | "XLM"; +} + +export interface AuthUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + roles: UserRole[]; + stellarAddress: string | null; +}