From ae5a9e369cde6916024eeb449c3a3f1adcc54352 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 2 Aug 2026 19:31:27 +0300 Subject: [PATCH] Add Vitest setup and CI test step --- .github/workflows/ci.yml | 36 +++++++++++++++++ app/(dashboard)/documents/page.tsx | 55 ++++++++++++++++++++++---- app/p/[userId]/page.tsx | 5 ++- app/payment-callback/page.tsx | 7 +++- components/career/career-picker.tsx | 10 +++-- components/documents/payment-modal.tsx | 6 ++- components/landing/hero.tsx | 11 ++++-- components/landing/reveal.tsx | 7 +++- components/landing/services-intro.tsx | 15 ++++--- components/skills/record-on-base.tsx | 4 +- components/theme-toggle.tsx | 3 +- lib/password-validation.test.ts | 22 +++++++++++ lib/utils.test.ts | 9 +++++ package.json | 10 ++++- 14 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 lib/password-validation.test.ts create mode 100644 lib/utils.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43092b2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: [main, master] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.15.1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --config.strict-peer-dependencies=false + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/app/(dashboard)/documents/page.tsx b/app/(dashboard)/documents/page.tsx index f87e412..5bb0f5d 100644 --- a/app/(dashboard)/documents/page.tsx +++ b/app/(dashboard)/documents/page.tsx @@ -25,12 +25,21 @@ export default function DocumentsPage() { // After successful payment redirect: show document upload useEffect(() => { if (typeof window === "undefined") return - const params = new URLSearchParams(window.location.search) - if (params.get("payment") === "success") { - setHasCredit(true) - setShowPaymentModal(false) - setShowUpload(true) - window.history.replaceState({}, "", "/documents") + + const url = new URL(window.location.href) + if (url.searchParams.get("payment") === "success") { + url.searchParams.delete("payment") + + const cleanUrl = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ""}${url.hash}` + window.history.replaceState({}, "", cleanUrl) + + const timer = window.setTimeout(() => { + setHasCredit(true) + setShowPaymentModal(false) + setShowUpload(true) + }, 0) + + return () => window.clearTimeout(timer) } }, []) @@ -63,12 +72,42 @@ export default function DocumentsPage() { } useEffect(() => { - fetchDocuments() + const timer = window.setTimeout(() => { + void (async () => { + try { + const response = await fetch("/api/documents") + if (response.ok) { + const data = await response.json() + setDocuments(data) + } + } catch (error) { + console.error("[v0] Failed to fetch documents:", error) + } finally { + setIsLoading(false) + } + })() + }, 0) + return () => window.clearTimeout(timer) }, []) useEffect(() => { if (showUpload || showPaymentModal) return - fetchCredits() + const timer = window.setTimeout(() => { + void (async () => { + try { + const res = await fetch("/api/payments/credits") + if (res.ok) { + const data = await res.json() + setHasCredit(data.hasCredit) + } else { + setHasCredit(false) + } + } catch { + setHasCredit(false) + } + })() + }, 0) + return () => window.clearTimeout(timer) }, [showUpload, showPaymentModal]) const handleDelete = async (doc: Document) => { diff --git a/app/p/[userId]/page.tsx b/app/p/[userId]/page.tsx index b2c46a5..a2961c3 100644 --- a/app/p/[userId]/page.tsx +++ b/app/p/[userId]/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link" import { getPublicUserProfile } from "@/lib/db" import { notFound } from "next/navigation" import { Badge } from "@/components/ui/badge" @@ -182,9 +183,9 @@ export default async function PublicProfilePage({ params }: PageProps) { diff --git a/app/payment-callback/page.tsx b/app/payment-callback/page.tsx index cb9204c..26cd656 100644 --- a/app/payment-callback/page.tsx +++ b/app/payment-callback/page.tsx @@ -3,13 +3,16 @@ import { useEffect, useState } from "react" export default function PaymentCallbackPage() { - const [status, setStatus] = useState<"verifying" | "success" | "failed">("verifying") + const [status, setStatus] = useState<"verifying" | "success" | "failed">(() => { + if (typeof window === "undefined") return "verifying" + const params = new URLSearchParams(window.location.search) + return params.get("reference") ? "verifying" : "failed" + }) useEffect(() => { const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "") const reference = params.get("reference") if (!reference) { - setStatus("failed") return } diff --git a/components/career/career-picker.tsx b/components/career/career-picker.tsx index f2c247e..b8491dd 100644 --- a/components/career/career-picker.tsx +++ b/components/career/career-picker.tsx @@ -45,13 +45,15 @@ export function CareerPicker({ if (value) return // Already selected; don't search const trimmed = query.trim() if (trimmed.length < 2) { - setSuggestions([]) - setLoading(false) - return + const timer = window.setTimeout(() => { + setSuggestions([]) + setLoading(false) + }, 0) + return () => window.clearTimeout(timer) } const reqId = ++requestIdRef.current - setLoading(true) const t = setTimeout(async () => { + setLoading(true) try { const res = await fetch(`/api/onet/careers?q=${encodeURIComponent(trimmed)}`) if (!res.ok) { diff --git a/components/documents/payment-modal.tsx b/components/documents/payment-modal.tsx index 37a01e0..37e2b40 100644 --- a/components/documents/payment-modal.tsx +++ b/components/documents/payment-modal.tsx @@ -47,13 +47,15 @@ export function PaymentModal({ open, onClose, onSuccess }: PaymentModalProps) { // Reset state every time the modal opens useEffect(() => { - if (open) { + if (!open) return + const timer = window.setTimeout(() => { setPhase("form") setError(null) setStatusMessage("") setReference(null) setSubmitting(false) - } + }, 0) + return () => window.clearTimeout(timer) }, [open]) // Polling loop while waiting for STK push to settle diff --git a/components/landing/hero.tsx b/components/landing/hero.tsx index 12b23c6..033ee02 100644 --- a/components/landing/hero.tsx +++ b/components/landing/hero.tsx @@ -1,3 +1,4 @@ +import Image from "next/image" import Link from "next/link" import { ArrowUpRight } from "lucide-react" import { TrustedBy } from "@/components/landing/trusted-by" @@ -56,10 +57,11 @@ export function Hero() { {/* Skill Growth card */}
- Skill @@ -71,10 +73,11 @@ export function Hero() { {/* Wide chart card — grows to align its bottom with the hero buttons */}
-
diff --git a/components/landing/reveal.tsx b/components/landing/reveal.tsx index 2bcd09f..d0c1e37 100644 --- a/components/landing/reveal.tsx +++ b/components/landing/reveal.tsx @@ -25,7 +25,7 @@ export function Reveal({ children }: { children: React.ReactNode }) { const reduce = window.matchMedia("(prefers-reduced-motion: reduce)") if (!desktop.matches || reduce.matches) return - setActive(true) + const timer = window.setTimeout(() => setActive(true), 0) const io = new IntersectionObserver( ([entry]) => { @@ -38,7 +38,10 @@ export function Reveal({ children }: { children: React.ReactNode }) { { threshold: 0, rootMargin: "-12% 0px -12% 0px" }, ) io.observe(el) - return () => io.disconnect() + return () => { + io.disconnect() + window.clearTimeout(timer) + } }, []) const transform = diff --git a/components/landing/services-intro.tsx b/components/landing/services-intro.tsx index 1e2923e..af6e581 100644 --- a/components/landing/services-intro.tsx +++ b/components/landing/services-intro.tsx @@ -1,5 +1,6 @@ "use client" +import Image from "next/image" import { Play, Plus, X } from "lucide-react" import { useState, useEffect, useCallback } from "react" @@ -39,10 +40,11 @@ export function ServicesIntro() {
{/* Left — dark stat card */}
-
{[0, 1, 2, 3].map((i) => ( - ))} @@ -87,10 +91,11 @@ export function ServicesIntro() {
{/* Image clipped to rounded corners */}
-
diff --git a/components/skills/record-on-base.tsx b/components/skills/record-on-base.tsx index 56f816d..e734edc 100644 --- a/components/skills/record-on-base.tsx +++ b/components/skills/record-on-base.tsx @@ -75,8 +75,8 @@ export function RecordOnBase({ skills }: RecordOnBaseProps) { useEffect(() => { if (!linkedAddress) { - setVerifiedOnChain(null) - return + const timer = window.setTimeout(() => setVerifiedOnChain(null), 0) + return () => window.clearTimeout(timer) } const fetchVerified = async () => { try { diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index 31b453b..8d1d8c1 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -11,7 +11,8 @@ export function ThemeToggle() { // useEffect only runs on the client, so now we can safely show the UI React.useEffect(() => { - setMounted(true) + const timer = window.setTimeout(() => setMounted(true), 0) + return () => window.clearTimeout(timer) }, []) if (!mounted) { diff --git a/lib/password-validation.test.ts b/lib/password-validation.test.ts new file mode 100644 index 0000000..33a7cf6 --- /dev/null +++ b/lib/password-validation.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { validatePasswordStrength } from "./password-validation" + +describe("validatePasswordStrength", () => { + it("accepts a strong password", () => { + const result = validatePasswordStrength("StrongPass123!") + + expect(result.isValid).toBe(true) + expect(result.strength).toBe("strong") + expect(result.errors).toEqual([]) + }) + + it("rejects a weak password", () => { + const result = validatePasswordStrength("password") + + expect(result.isValid).toBe(false) + expect(result.strength).toBe("weak") + expect(result.errors).toContain("Password must contain at least one uppercase letter") + expect(result.errors).toContain("Password must contain at least one number") + expect(result.errors).toContain("Password must contain at least one special character (!@#$%^&*...)") + }) +}) diff --git a/lib/utils.test.ts b/lib/utils.test.ts new file mode 100644 index 0000000..430c043 --- /dev/null +++ b/lib/utils.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest" +import { cn } from "./utils" + +describe("cn", () => { + it("merges class names and resolves Tailwind conflicts", () => { + expect(cn("px-2", "px-4", "text-sm")).toBe("px-4 text-sm") + expect(cn("bg-red-500", "bg-blue-500")).toBe("bg-blue-500") + }) +}) diff --git a/package.json b/package.json index 3a20bc6..ea91ecd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "build": "next build", "dev": "next dev", - "lint": "eslint .", + "lint": "eslint --config eslint.config.cjs .", + "typecheck": "tsc --noEmit", + "test": "vitest run", "start": "next start", "db:migrate": "node scripts/run-migrations.mjs", "onet:import": "node scripts/onet-import.mjs", @@ -74,15 +76,19 @@ "zod": "3.25.76" }, "devDependencies": { + "@eslint/eslintrc": "^2.1.4", "@tailwindcss/postcss": "^4.1.9", "@types/node": "^22", "@types/nodemailer": "^7.0.5", "@types/react": "^19", "@types/react-dom": "^19", + "eslint": "^9.22.0", + "eslint-config-next": "^16.2.12", "pg": "^8.20.0", "postcss": "^8.5", "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }