Skip to content
Open
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 dongle/__tests__/components/ProjectLifecycleStatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProjectLifecycleStatusBadge } from "@/components/projects/ProjectLifecycleStatusBadge";
import { PROJECT_STATUS_LABELS } from "@/types/project";

describe("ProjectLifecycleStatusBadge", () => {
it("renders the label for a given status", () => {
render(<ProjectLifecycleStatusBadge status="archived" />);
expect(screen.getByText(PROJECT_STATUS_LABELS.archived)).toBeInTheDocument();
});

it("defaults to Active when status is omitted", () => {
render(<ProjectLifecycleStatusBadge />);
expect(screen.getByText(PROJECT_STATUS_LABELS.active)).toBeInTheDocument();
});

it("renders every known lifecycle status", () => {
const { rerender } = render(<ProjectLifecycleStatusBadge status="active" />);
for (const status of ["active", "paused", "deprecated", "archived", "flagged", "removed"] as const) {
rerender(<ProjectLifecycleStatusBadge status={status} />);
expect(screen.getByText(PROJECT_STATUS_LABELS[status])).toBeInTheDocument();
}
});
});
44 changes: 43 additions & 1 deletion dongle/__tests__/pages/Discover.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
import React from "react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import DiscoverPage from "@/app/discover/page";
import { mockProjects } from "@/data/mockProjects";
import { projectService } from "@/services/project/project.service";
import type { SortBy } from "@/hooks/useDiscoverParams";

// jsdom in the current Node version does not implement window.matchMedia, but
// ProjectCard calls getPrefetchValue() which reads it. Provide a minimal stub.
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
});

// Mock soroban so verification fetches resolve immediately
vi.mock("@/services/stellar/soroban.service", () => ({
sorobanService: {
Expand Down Expand Up @@ -241,6 +259,30 @@ describe("Discover Page - High Risk Flows", () => {
expect(screen.getByText(mockProjects[0].name)).toBeInTheDocument();
});

it("shows lifecycle status badges on project cards", async () => {
render(<DiscoverPage />);
await finishInitialLoad();

const deprecatedProjects = mockProjects.filter((p) => p.status === "deprecated");
expect(deprecatedProjects.length).toBeGreaterThan(0);
expect(screen.getAllByText("Deprecated").length).toBeGreaterThan(0);
});

it("filters projects by lifecycle status", async () => {
render(<DiscoverPage />);
await finishInitialLoad();

const statusSelect = screen.getByLabelText(
/Filter by lifecycle status/i,
) as HTMLSelectElement;
fireEvent.change(statusSelect, { target: { value: "deprecated" } });

const deprecatedProject = mockProjects.find((p) => p.status === "deprecated");
expect(deprecatedProject).toBeTruthy();
expect(screen.getByText(deprecatedProject!.name)).toBeInTheDocument();
expect(screen.queryByText(mockProjects[0].name)).not.toBeInTheDocument();
});

it("filters projects by category", async () => {
render(<DiscoverPage />);
await finishInitialLoad();
Expand Down
46 changes: 46 additions & 0 deletions dongle/__tests__/pages/ProjectDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,49 @@ describe("Project Detail Page - layout shell", () => {
expect(shell?.className).toMatch(/pb-24/);
});
});

describe("Project Detail Page - lifecycle status", () => {
const mockProject = {
id: "test-project-id",
name: "Test Secure Project",
category: PROJECT_CATEGORIES.DEFI as ProjectCategory,
primaryCategory: PROJECT_CATEGORIES.DEFI as ProjectCategory,
description: "A test project description.",
rating: 4.8,
reviews: 5,
createdAt: "2026-01-01T00:00:00Z",
websiteUrl: "https://secure-test.xyz",
githubUrl: "https://github.com/secure-test/repo",
ownerAddress: "G_OWNER_123",
};

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.mocked(useConfirm).mockReturnValue(vi.fn());
vi.mocked(sorobanService.getVerificationStatus).mockResolvedValue("NONE");
vi.mocked(projectService.getProjectById).mockReturnValue(mockProject);
});

afterEach(() => {
vi.useRealTimers();
});

it("shows the lifecycle status badge on the detail page", async () => {
render(<ProjectDetailPage />);
await finishInitialLoad();
expect(screen.getAllByText("Active").length).toBeGreaterThan(0);
});

it("lets the owner update the project status", async () => {
render(<ProjectDetailPage />);
await finishInitialLoad();

const statusSelect = screen.getByLabelText(/Update project status/i) as HTMLSelectElement;
expect(statusSelect).toBeTruthy();

fireEvent.change(statusSelect, { target: { value: "archived" } });
expect(statusSelect.value).toBe("archived");
expect(screen.getAllByText("Archived").length).toBeGreaterThan(0);
});
});
110 changes: 110 additions & 0 deletions dongle/__tests__/services/project-status.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { projectStatusService } from "@/services/project/project-status.service";
import { mockProjects } from "@/data/mockProjects";
import {
getProjectStatus,
getProjectStatusLabel,
isProjectActive,
PROJECT_STATUSES,
} from "@/types/project";

// The current Node/jsdom combo does not expose `localStorage` on the global
// object, so provide a tiny in-memory implementation for hermetic tests.
function createLocalStorageMock() {
let store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
},
};
}

describe("projectStatusService", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createLocalStorageMock());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("returns null when no override exists", () => {
expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull();
});

it("persists and reads a status override", () => {
projectStatusService.setProjectStatus("proj-0", "archived");
expect(projectStatusService.getProjectStatusOverride("proj-0")).toBe("archived");
});

it("ignores invalid persisted status values", () => {
projectStatusService.setProjectStatus("proj-0", "active");
localStorage.setItem("dongle_project_status_overrides", JSON.stringify({ "proj-0": "bogus" }));
expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull();
});

it("clears a status override", () => {
projectStatusService.setProjectStatus("proj-0", "archived");
projectStatusService.clearProjectStatus("proj-0");
expect(projectStatusService.getProjectStatusOverride("proj-0")).toBeNull();
});

it("applies override to a project copy without mutating the seed", () => {
const project = mockProjects[0];
projectStatusService.setProjectStatus(project.id, "removed");
const applied = projectStatusService.applyOverride(project);
expect(applied.status).toBe("removed");
expect(applied).not.toBe(project);
expect(project.status).toBe("active");
});

it("leaves the project unchanged when no override exists", () => {
const project = mockProjects[0];
expect(projectStatusService.applyOverride(project)).toBe(project);
});
});

describe("project status helpers", () => {
it("treats a missing status as active", () => {
expect(getProjectStatus(undefined)).toBe("active");
expect(getProjectStatusLabel(undefined)).toBe("Active");
expect(isProjectActive(undefined)).toBe(true);
});

it("maps every known status to a label", () => {
for (const status of PROJECT_STATUSES) {
expect(getProjectStatusLabel(status)).toEqual(
status.charAt(0).toUpperCase() + status.slice(1),
);
}
});

it("flags non-active lifecycle states as inactive", () => {
expect(isProjectActive("paused")).toBe(false);
expect(isProjectActive("deprecated")).toBe(false);
expect(isProjectActive("archived")).toBe(false);
expect(isProjectActive("flagged")).toBe(false);
expect(isProjectActive("removed")).toBe(false);
expect(isProjectActive("active")).toBe(true);
});
});

describe("mock project seed statuses", () => {
it("seeds a representative mix of lifecycle states", () => {
const statuses = new Set(mockProjects.map((p) => p.status));
for (const inactive of ["deprecated", "paused", "archived", "flagged"] as const) {
expect(statuses.has(inactive)).toBe(true);
}
});
});
41 changes: 40 additions & 1 deletion dongle/__tests__/services/project.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { projectService } from "@/services/project/project.service";
import { projectStatusService } from "@/services/project/project-status.service";
import { mockProjects } from "@/data/mockProjects";

// The current Node/jsdom combo does not expose `localStorage` on the global
// object, so provide a tiny in-memory implementation for hermetic tests.
function createLocalStorageMock() {
let store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
},
};
}

describe("projectService", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createLocalStorageMock());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("returns all mock projects", () => {
const all = projectService.getAllProjects();
expect(all.length).toEqual(mockProjects.length);
Expand Down Expand Up @@ -47,4 +78,12 @@ describe("projectService", () => {
new Date(byNewest[1].createdAt).getTime(),
);
});

it("reflects persisted lifecycle-status overrides", () => {
const project = mockProjects[0];
projectStatusService.setProjectStatus(project.id, "archived");
const all = projectService.getAllProjects();
expect(all.find((p) => p.id === project.id)?.status).toBe("archived");
expect(projectService.getProjectById(project.id)?.status).toBe("archived");
});
});
45 changes: 43 additions & 2 deletions dongle/app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { SortBy } from "@/hooks/useDiscoverParams";
import { TagInput } from "@/components/ui/TagInput";
import { sorobanService } from "@/services/stellar/soroban.service";
import type { VerificationStatus } from "@/components/projects/VerificationBadge";
import { PROJECT_STATUSES, PROJECT_STATUS_LABELS, type ProjectStatus } from "@/types/project";
import { useRecentViews } from "@/hooks/useRecentViews";
import { RecentlyViewedProjects } from "@/components/projects/RecentlyViewedProjects";
import { useWalletPageGate } from "@/hooks/useWalletPageGate";
Expand All @@ -25,6 +26,7 @@ function DiscoverContent() {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [verificationStatuses, setVerificationStatuses] = useState<Record<string, VerificationStatus>>({});
const [verificationFilter, setVerificationFilter] = useState<VerificationStatus | "ALL">("ALL");
const [statusFilter, setStatusFilter] = useState<ProjectStatus | "ALL">("ALL");
const gate = useWalletPageGate();
const { recentProjects, hasHistory } = useRecentViews(gate.publicKey || undefined);

Expand Down Expand Up @@ -87,9 +89,13 @@ function DiscoverContent() {
result = result.filter((p) => verificationStatuses[p.id] === verificationFilter);
}

if (statusFilter !== "ALL") {
result = result.filter((p) => (p.status ?? "active") === statusFilter);
}

result = projectService.sortProjects(result, sortBy);
return result;
}, [searchQuery, category, tags, sortBy, verificationFilter, verificationStatuses]);
}, [searchQuery, category, tags, sortBy, verificationFilter, verificationStatuses, statusFilter]);

const filteredCount = filteredAndSortedProjects.length;
const visibleCount = page * ITEMS_PER_PAGE;
Expand Down Expand Up @@ -138,6 +144,15 @@ function DiscoverContent() {
});
};

const handleStatusFilterChange = (value: ProjectStatus | "ALL") => {
setStatusFilter(value);
trackFilter({
filterType: "lifecycle-status",
filterValue: value,
source: "discover",
});
};

const handleTagsChange = (nextTags: string[]) => {
setTags(nextTags);
trackFilter({
Expand All @@ -155,6 +170,12 @@ function DiscoverContent() {
}, 600);
};

const handleClearFilters = () => {
clearFilters();
setStatusFilter("ALL");
setVerificationFilter("ALL");
};

return (
<main className="min-h-screen pt-8 pb-24 bg-zinc-50 dark:bg-zinc-950">
<div className="container mx-auto px-4">
Expand Down Expand Up @@ -201,6 +222,26 @@ function DiscoverContent() {

<div className="h-8 w-px bg-zinc-200 dark:bg-zinc-800 hidden lg:block mx-2" />

{/* Lifecycle Status Filter */}
<select
value={statusFilter}
onChange={(e) =>
handleStatusFilterChange(e.target.value as ProjectStatus | "ALL")
}
disabled={isInitialLoading}
aria-label="Filter by lifecycle status"
className="px-4 py-2 bg-zinc-100 dark:bg-zinc-800 border border-transparent rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="ALL">All Status</option>
{PROJECT_STATUSES.map((status) => (
<option key={status} value={status}>
{PROJECT_STATUS_LABELS[status]}
</option>
))}
</select>

<div className="h-8 w-px bg-zinc-200 dark:bg-zinc-800 hidden lg:block mx-2" />

{/* Verification Filter */}
<select
value={verificationFilter}
Expand Down Expand Up @@ -280,7 +321,7 @@ function DiscoverContent() {
Try adjusting your search or filters to find what you&apos;re
looking for.
</p>
<Button variant="outline" className="mt-6" onClick={clearFilters}>
<Button variant="outline" className="mt-6" onClick={handleClearFilters}>
Clear Filters
</Button>
</div>
Expand Down
Loading