feat(FN-367): add inline retry affordance after VIN decode failure (FN-367, gitea #11) #12
@@ -61,7 +61,7 @@ describe("Telemetry module", () => {
|
||||
instrumentations: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("should return null when endpoint is not configured", async () => {
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = undefined;
|
||||
|
||||
172
apps/web/src/routes/__tests__/dashboard-search.test.tsx
Normal file
172
apps/web/src/routes/__tests__/dashboard-search.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.code = String(status);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/faro", () => ({
|
||||
startAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn().mockReturnValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual = await vi.importActual<any>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
createFileRoute: () => (options: any) => ({ options }),
|
||||
Link: ({ children, to, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
|
||||
// Import component after mocks
|
||||
let SearchPage: React.ComponentType;
|
||||
beforeAll(async () => {
|
||||
const mod = await import("@/routes/dashboard/search");
|
||||
// The route component is the default export or named SearchPage
|
||||
// createFileRoute wraps it, so we need the component from the Route
|
||||
SearchPage = (mod as any).Route?.options?.component ?? (mod as any).default;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
function renderSearch() {
|
||||
return render(<SearchPage />);
|
||||
}
|
||||
|
||||
function typeVin(vin: string) {
|
||||
const input = screen.getByPlaceholderText(/Şase numarasını girin/i);
|
||||
fireEvent.change(input, { target: { value: vin } });
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const button = screen.getByRole("button", { name: /Şase Çöz/i });
|
||||
fireEvent.click(button);
|
||||
}
|
||||
|
||||
const TEST_VIN = "WVWZZZ1JZ3W597935";
|
||||
|
||||
test("retry happy path: shows banner, retry button calls api twice and emits events", async () => {
|
||||
const { ApiError: MockApiError } = await import("@/lib/api-client");
|
||||
(api.post as any)
|
||||
.mockRejectedValueOnce(new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"))
|
||||
.mockResolvedValueOnce({ id: "veh-1" });
|
||||
|
||||
renderSearch();
|
||||
typeVin(TEST_VIN);
|
||||
await submitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Şase çözümlenemedi");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Servis geçici olarak kullanılamıyor");
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: /Tekrar Dene/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(api.post).toHaveBeenNthCalledWith(1, "/vehicles/decode", { vin: TEST_VIN });
|
||||
expect(api.post).toHaveBeenNthCalledWith(2, "/vehicles/decode", { vin: TEST_VIN });
|
||||
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
"vin_decode_retry_clicked",
|
||||
expect.objectContaining({ attempt: 2 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
"vin_decode_success",
|
||||
expect.objectContaining({ vin: TEST_VIN, vehicle_id: "veh-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("retry suppressed for unrecognized VIN (tanınamadı)", async () => {
|
||||
const { ApiError: MockApiError } = await import("@/lib/api-client");
|
||||
(api.post as any).mockRejectedValueOnce(new (MockApiError as any)(404, "Şase tanınamadı"));
|
||||
|
||||
renderSearch();
|
||||
typeVin(TEST_VIN);
|
||||
await submitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /sistem yöneticisine gönder/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("retry suppressed for subscription block (abone olun)", async () => {
|
||||
const { ApiError: MockApiError } = await import("@/lib/api-client");
|
||||
(api.post as any).mockRejectedValueOnce(new (MockApiError as any)(403, "Lütfen abone olun"));
|
||||
|
||||
renderSearch();
|
||||
typeVin(TEST_VIN);
|
||||
await submitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
|
||||
import { Badge, Button, Input, Separator } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
|
||||
import { AlertCircle, Car, Clock, Loader2, RotateCcw, Search, Send } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
@@ -50,6 +50,8 @@ function SearchPage() {
|
||||
const focusFiredRef = useRef(false);
|
||||
const querySourceRef = useRef<"manual" | "paste" | "history">("manual");
|
||||
const candidatesShownAtRef = useRef<number | null>(null);
|
||||
const lastAttemptedVinRef = useRef<string | null>(null);
|
||||
const attemptCountRef = useRef<number>(0);
|
||||
const [vin, setVin] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -135,32 +137,15 @@ function SearchPage() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
// ─── Decode runner (shared by submit and retry) ────────────────────────────
|
||||
async function runDecode(cleanVin: string, attempt: number) {
|
||||
setError(null);
|
||||
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
const querySource = querySourceRef.current;
|
||||
startAction("vin-decode", { vin: cleanVin });
|
||||
capture("vin_decoded", { vin: cleanVin, query_source: querySource });
|
||||
if (!isValidVin(cleanVin)) {
|
||||
capture("search_input_validation_failed", {
|
||||
field: "vin",
|
||||
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
|
||||
input_length: cleanVin.length,
|
||||
});
|
||||
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const decodeStart = performance.now();
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
const responseTimeMs = Math.round(performance.now() - decodeStart);
|
||||
|
||||
// Handle multiple vehicle candidates (PartsCatalogs or EMEX)
|
||||
if (data.candidates && Array.isArray(data.candidates)) {
|
||||
setCandidates(data.candidates);
|
||||
setCandidateVin(cleanVin);
|
||||
@@ -171,9 +156,8 @@ function SearchPage() {
|
||||
count: data.candidates.length,
|
||||
source: data.source,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySource,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,12 +166,9 @@ function SearchPage() {
|
||||
vehicle_id: data.id,
|
||||
response_time_ms: responseTimeMs,
|
||||
source: data.source ?? null,
|
||||
query_source: querySource,
|
||||
});
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id",
|
||||
params: { id: data.id },
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
||||
} catch (err) {
|
||||
const responseTimeMs = Math.round(performance.now() - decodeStart);
|
||||
const message =
|
||||
@@ -197,18 +178,53 @@ function SearchPage() {
|
||||
error: message,
|
||||
response_time_ms: responseTimeMs,
|
||||
status_code: err instanceof ApiError ? err.status : null,
|
||||
query_source: querySource,
|
||||
query_source: querySourceRef.current,
|
||||
attempt,
|
||||
});
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.");
|
||||
}
|
||||
setError(message);
|
||||
toast.error("Şase arama başarısız");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
const vinToRetry = lastAttemptedVinRef.current;
|
||||
if (!vinToRetry || loading) return;
|
||||
attemptCountRef.current += 1;
|
||||
capture("vin_decode_retry_clicked", {
|
||||
vin: vinToRetry,
|
||||
attempt: attemptCountRef.current,
|
||||
previous_error: error,
|
||||
});
|
||||
runDecode(vinToRetry, attemptCountRef.current);
|
||||
}
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
const querySource = querySourceRef.current;
|
||||
startAction("vin-decode", { vin: cleanVin });
|
||||
capture("vin_decoded", { vin: cleanVin, query_source: querySource });
|
||||
|
||||
if (!isValidVin(cleanVin)) {
|
||||
capture("search_input_validation_failed", {
|
||||
field: "vin",
|
||||
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
|
||||
input_length: cleanVin.length,
|
||||
});
|
||||
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
||||
return;
|
||||
}
|
||||
|
||||
lastAttemptedVinRef.current = cleanVin;
|
||||
attemptCountRef.current = 1;
|
||||
await runDecode(cleanVin, 1);
|
||||
}
|
||||
|
||||
async function handleReportVin() {
|
||||
setReportSending(true);
|
||||
try {
|
||||
@@ -392,26 +408,51 @@ function SearchPage() {
|
||||
Şase Çöz
|
||||
</Button>
|
||||
|
||||
{/* Error card */}
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error.includes("abone olun") ? (
|
||||
<>
|
||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||
>
|
||||
abone olun
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="space-y-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-destructive">Şase çözümlenemedi</p>
|
||||
<p className="mt-1 text-sm text-destructive/90">
|
||||
{error.includes("abone olun") ? (
|
||||
<>
|
||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||
>
|
||||
abone olun
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!error.includes("abone olun") && !error.includes("tanınamadı") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={loading || !lastAttemptedVinRef.current}
|
||||
className="h-11 w-full rounded-xl"
|
||||
data-faro-user-action-name="vin-decode-retry"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
)}
|
||||
Tekrar Dene
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user