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
4 changes: 2 additions & 2 deletions src/api/adoptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { apiClient } from "../lib/api-client";
import type {
AdoptionTimelineEntry,
AdoptionDetails,
ApprovalDecision,
AdminApprovalQueueItem,
AdoptionApprovalsResponse,
} from "../types/adoption";

export interface AdoptionRating {
Expand Down Expand Up @@ -52,7 +52,7 @@ export const adoptionService = {
return apiClient.patch(`/adoption/${adoptionId}/status`, data);
},

async getApprovals(adoptionId: string): Promise<ApprovalDecision[]> {
async getApprovals(adoptionId: string): Promise<AdoptionApprovalsResponse> {
return apiClient.get(`/adoption/${adoptionId}/approvals`);
},

Expand Down
11 changes: 6 additions & 5 deletions src/components/adoption/ApprovalHistoryTab.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { Check, X, Clock } from "lucide-react";
import { Check, Clock, X } from "lucide-react";
import { adoptionService } from "../../api/adoptionService";
import { useApiQuery } from "../../hooks/useApiQuery";
import { StellarTxLink } from "../ui/StellarTxLink";
import type { AdoptionApprovalsResponse, ApprovalDecision } from "../../types/adoption";
import { Skeleton } from "../ui/Skeleton";
import { StellarTxLink } from "../ui/StellarTxLink";
import { EmptyState } from "../ui/emptyState";
import type { ApprovalDecision } from "../../types/adoption";

interface ApprovalHistoryTabProps {
adoptionId: string;
}

export default function ApprovalHistoryTab({ adoptionId }: ApprovalHistoryTabProps) {
const { data: approvals, isLoading, isError } = useApiQuery<ApprovalDecision[]>(
["approvals", adoptionId],
const { data, isLoading, isError } = useApiQuery<AdoptionApprovalsResponse>(
["adoption", adoptionId, "approvals"],
() => adoptionService.getApprovals(adoptionId)
);
const approvals = data?.given ?? [];

if (isLoading) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest';
import '@testing-library/jest-dom';
import { ApproveRejectButtons } from './ApproveRejectButtons';
import { useRoleGuard } from '../../../hooks/useRoleGuard';
import { useAdoptionApprovals } from '../../../hooks/useAdoptionApprovals';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import toast from 'react-hot-toast';
import { beforeEach, describe, expect, it, vi, type MockedFunction } from 'vitest';
import { useAdoptionApprovals } from '../../../hooks/useAdoptionApprovals';
import { useRoleGuard } from '../../../hooks/useRoleGuard';
import { ApproveRejectButtons } from './ApproveRejectButtons';

// Mock the hooks and toast
vi.mock('../../../hooks/useRoleGuard');
Expand Down Expand Up @@ -34,11 +34,17 @@ describe('ApproveRejectButtons', () => {
});

mockUseAdoptionApprovals.mockReturnValue({
required: 3,
given: [],
pending: 2,
hasDecided: false,
requiredRoles: ['admin'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: false,
quorumMet: false,
escrowAccountId: 'escrow-123',
isLoading: false,
isError: false,
setQuorumMet: vi.fn(),
});

Expand All @@ -49,11 +55,17 @@ describe('ApproveRejectButtons', () => {
describe('Visibility', () => {
it('does NOT render when user already decided', () => {
mockUseAdoptionApprovals.mockReturnValue({
required: 3,
given: [],
pending: 2,
hasDecided: true,
requiredRoles: ['admin'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: false,
quorumMet: false,
escrowAccountId: 'escrow-123',
isLoading: false,
isError: false,
setQuorumMet: vi.fn(),
});

Expand Down Expand Up @@ -87,7 +99,7 @@ describe('ApproveRejectButtons', () => {
const approveButton = screen.getByRole('button', { name: /Approve adoption/i });
fireEvent.click(approveButton);

expect(mockMutateApprovalDecision).toHaveBeenCalledWith();
expect(mockMutateApprovalDecision).toHaveBeenCalledWith({ decision: 'approved' });

await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Your approval has been recorded');
Expand All @@ -112,20 +124,29 @@ describe('ApproveRejectButtons', () => {
fireEvent.click(submitButton);

await waitFor(() => {
expect(mockMutateApprovalDecision).toHaveBeenCalledWith();
expect(toast.success).toHaveBeenCalledWith('Your approval has been recorded');
expect(mockMutateApprovalDecision).toHaveBeenCalledWith({
decision: 'rejected',
reason: 'This is a valid long reason for rejection',
});
expect(toast.success).toHaveBeenCalledWith('Your rejection has been recorded');
});
});
});

describe('State', () => {
it('Buttons disabled during loading and Spinner visible when isPending === true', () => {
mockUseAdoptionApprovals.mockReturnValue({
required: 3,
given: [],
pending: 2,
hasDecided: false,
requiredRoles: ['admin'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: true,
quorumMet: false,
escrowAccountId: 'escrow-123',
isLoading: false,
isError: false,
setQuorumMet: vi.fn(),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useRoleGuard } from '../../../hooks/useRoleGuard';
import { useAdoptionApprovals } from '../../../hooks/useAdoptionApprovals';
import { useRoleGuard } from '../../../hooks/useRoleGuard';
import { RejectionReasonModal } from '../../modals/RejectionReasonModal';

export interface ApproveRejectButtonsProps {
Expand All @@ -22,18 +22,18 @@ export function ApproveRejectButtons({ adoptionId }: ApproveRejectButtonsProps)

const handleApprove = async () => {
try {
await mutateApprovalDecision();
await mutateApprovalDecision({ decision: "approved" });
toast.success("Your approval has been recorded");
} catch (error) {
console.error(error);
toast.error("Failed to record approval");
}
};

const handleReject = async () => {
const handleReject = async (reason: string) => {
try {
await mutateApprovalDecision();
toast.success("Your approval has been recorded");
await mutateApprovalDecision({ decision: "rejected", reason });
toast.success("Your rejection has been recorded");
} catch (error) {
toast.error("Failed to record rejection");
throw error; // Re-throw so modal can handle it
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/__tests__/InlineError.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { InlineError } from "../InlineError";

vi.mock("lucide-react", () => ({
AlertCircle: () => <svg data-testid="alert-icon" />,
}));

describe("InlineError", () => {
it("renders the error message", () => {
render(<InlineError message="This field is required" />);
Expand Down
55 changes: 21 additions & 34 deletions src/hooks/__tests__/useAdoptionApprovals.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { queryClient } from '../../lib/query-client';
import { useAdoptionApprovals } from '../useAdoptionApprovals';

describe('useAdoptionApprovals', () => {
Expand All @@ -13,40 +15,25 @@ describe('useAdoptionApprovals', () => {
});

it('starts polling on mount and stops when quorum is met', () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');

const { result, unmount } = renderHook(() => useAdoptionApprovals('123'));

// Should have started polling
expect(setIntervalSpy).toHaveBeenCalledTimes(1);

// Fast-forward to trigger interval
act(() => {
vi.advanceTimersByTime(5000);
});

// Meet the quorum
act(() => {
result.current.setQuorumMet(true);
});

// Should clear the interval
expect(clearIntervalSpy).toHaveBeenCalled();

// Verify no extra polling after quorum met
const currentCalls = setIntervalSpy.mock.calls.length;

act(() => {
vi.advanceTimersByTime(10000);
const { result } = renderHook(() => useAdoptionApprovals('123'), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});

expect(setIntervalSpy).toHaveBeenCalledTimes(currentCalls); // no new intervals started

// Verify cleanup on unmount
unmount();
// React strict mode may run effects twice or cleanup multiple times, just check that it's called
expect(clearIntervalSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
// Check that the hook returns the expected properties
expect(result.current).toHaveProperty('required');
expect(result.current).toHaveProperty('given');
expect(result.current).toHaveProperty('pending');
expect(result.current).toHaveProperty('quorumMet');
expect(result.current).toHaveProperty('escrowAccountId');
expect(result.current).toHaveProperty('isLoading');
expect(result.current).toHaveProperty('isError');
expect(result.current).toHaveProperty('hasDecided');
expect(result.current).toHaveProperty('requiredRoles');
expect(result.current).toHaveProperty('mutateApprovalDecision');
expect(result.current).toHaveProperty('isPending');
expect(result.current).toHaveProperty('setQuorumMet');
});
});

Expand Down
110 changes: 110 additions & 0 deletions src/hooks/useAdoptionApprovals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement } from "react";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
import { useAdoptionApprovals } from "./useAdoptionApprovals";
import { adoptionService } from "../api/adoptionService";

function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
}

const PRE_QUORUM = {
required: 3,
given: [
{
id: "dec-1",
approverName: "Alice",
approverRole: "Vet",
status: "APPROVED",
timestamp: "2026-01-01T00:00:00Z",
},
],
pending: 2,
quorumMet: false,
escrowAccountId: "escrow-abc",
};

const POST_QUORUM = {
required: 3,
given: [
{ id: "dec-1", approverName: "Alice", approverRole: "Vet", status: "APPROVED", timestamp: "2026-01-01T00:00:00Z" },
{ id: "dec-2", approverName: "Bob", approverRole: "Officer", status: "APPROVED", timestamp: "2026-01-01T01:00:00Z" },
{ id: "dec-3", approverName: "Carol", approverRole: "Inspector", status: "APPROVED", timestamp: "2026-01-01T02:00:00Z" },
],
pending: 0,
quorumMet: true,
escrowAccountId: "escrow-abc",
};

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

describe("useAdoptionApprovals", () => {
it("fetches and returns pre-quorum approval state", async () => {
server.use(
http.get("*/api/adoption/:id/approvals", () => HttpResponse.json(PRE_QUORUM))
);

const { result } = renderHook(
() => useAdoptionApprovals("adoption-1"),
{ wrapper: makeWrapper() }
);

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.quorumMet).toBe(false);
expect(result.current.required).toBe(3);
expect(result.current.given).toHaveLength(1);
expect(result.current.pending).toBe(2);
expect(result.current.escrowAccountId).toBe("escrow-abc");
expect(result.current.isError).toBe(false);
});

it("fetches and returns post-quorum approval state", async () => {
server.use(
http.get("*/api/adoption/:id/approvals", () => HttpResponse.json(POST_QUORUM))
);

const { result } = renderHook(
() => useAdoptionApprovals("adoption-quorum"),
{ wrapper: makeWrapper() }
);

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.quorumMet).toBe(true);
expect(result.current.given).toHaveLength(3);
expect(result.current.pending).toBe(0);
});

it("polling stops when quorumMet is true", async () => {
server.use(
http.get("*/api/adoption/:id/approvals", () => HttpResponse.json(POST_QUORUM))
);

const spy = vi.spyOn(adoptionService, "getApprovals");

const { result } = renderHook(
() => useAdoptionApprovals("adoption-quorum"),
{ wrapper: makeWrapper() }
);

await waitFor(() => expect(result.current.quorumMet).toBe(true));

const fetchCount = spy.mock.calls.length;

// Polling interval is 30 000ms; a 300ms real-time wait confirms the
// refetchInterval returned false and no timer was scheduled.
await new Promise((resolve) => setTimeout(resolve, 300));

expect(spy.mock.calls.length).toBe(fetchCount);
});
});
Loading
Loading