wrongmove: round-3 fix sweep — scrape pipeline, BUY tab, filter URL state, render hygiene, map polish
Coordinated fix across 31 bugs found in a parallel QA pass. Findings docs at /tmp/wrongmove-bugs/qa-round-3/qa{1,2,3,4}-*.md.
## Backend / scrape (Fix-1) — 8 bugs
- B1 [P0] Scrape totally broken on prod: pod UID 100 vs NFS dir 1000:1000 mode 775 → PermissionError on every never-seen listing. Switched Dockerfile to explicit `useradd --uid 1000 --gid 1000`; added securityContext + chown initContainer to k8s/{api,celery-beat}-deployment.yaml. Celery worker manifest lives outside this repo — Dockerfile UID change is the load-bearing fix.
- B4 [P1] Celery broker reaped every ~30s by Redis HAProxy idle timeout. Added `broker_transport_options` / `result_backend_transport_options` with `socket_keepalive=True, health_check_interval=25` in celery_app.py + same kwargs on every redis.from_url/Redis call across services/, utils/redis_lock.py, redis_repository.py.
- B5 [P1] dump_listings_task never published terminal FAILURE to the task_progress pub/sub channel — UI polled forever. Wrap body in try/except that publishes FAILURE before re-raising.
- B6 [P1] _process_worker had no per-listing exception handler — one bad listing killed the whole scrape via asyncio.gather. Wrap loop body in try/except Exception (re-raises CancelledError).
- B20 [P2] dump_listings_task gained time_limit=3600, soft_time_limit=3500, acks_late=True.
- B21 [P2] RedisRepository moved off shared db0 (was alongside paperless-ngx) to db3 via REDIS_USER_DB env var; keys prefixed `wrongmove:user:`.
- B32 [P3] redis_lock now uses uuid4() owner token + Lua compare-and-delete.
- B33 [P3] Slack notify in refresh_listings → asyncio.create_task (fire-and-forget).
## Frontend filter system (Fix-2) — 7 bugs
- B2 [P0] BUY tab click triggered "Maximum update depth exceeded" → ErrorBoundary. Replaced the three mutually-triggering useEffects in FilterBar with a single one-way controlled-value flow (URL → parent state → form), guarded by previousListingTypeRef so price-defaults fires once per real transition.
- B3 [P0] Filter values never reached the URL. Wired useFilterParams.setFilterValues into FilterBar/FilterPanel onSubmit + handleRemoveChip + new handleResetAllFilters; fed parsed filterValues into both forms' defaultValues; added URL→form sync via form.reset on browser back/forward.
- B8 [P1] Chip removal now resets form state via new FilterBar onFormReady callback — More badge no longer sticks.
- B12 [P2] Desktop swipe-review FAB added next to header (mobile FAB unchanged).
- B17 [P2] "Reset all" affordance on chip strip.
- B22 [P2] formatPrice precision: 1500 → £1.5k, 2500 → £2.5k (no longer collides with £2k/£3k defaults).
- B30 [P3] last_seen_days input gained min={0}.
## Frontend render hygiene + data integrity (Fix-3) — 8 bugs
- B7 [P1] streamingService bails on first non-NDJSON chunk (HTML response = backend down) and throws StreamParseError so the existing AlertError dialog surfaces a single user-visible error instead of 18× console.error spam.
- B9 [P1] formatDuration widened to (null|undefined|number): returns "—" for non-finite or negative, caps implausibly large values.
- B10 [P1] PropertyCard / PropertyCardCompact / SwipeCard JSX leaves render "—" for null total_price/qm/qmprice (was "£0/0 m²/£0/m²" — looked like free listings).
- B13 [P2] hexgrid worker reduceAverage uses Number.isFinite filter instead of !isNaN (which incorrectly accepted null → 0, biasing per-hex averages low).
- B14 [P2] ListingDetail Overview wraps agency in "Listed by" labelled block so it can't collapse to a bare agency name.
- B15 [P2] Compact POIDistanceBadges iterates all three travel modes with "—" for missing, matching the detail-sheet Travel table.
- B24 [P3] Drawer.Description (sr-only) added to ListingDetailSheet + MobileBottomSheet to silence Radix a11y warning.
- B25 [P3] lastSeenDays clamped to ≥0 so future timestamps don't render as "-7d ago".
## Frontend map / carousel / tasks polish (Fix-4) — 8 bugs
- B11 [P2] HexgridHeatmapClient destroy race: Map.tsx adds .catch() + ref guard so post-destroy promise rejections are silent no-ops. Verified by browser smoke (24 rapid Map↔List toggles → 0 pageErrors).
- B16 [P2] PhotoCarousel + inner CardCarousel gained keyboard nav (Arrow keys).
- B18 [P2] Default map center moved from Czech Republic to London (zoom 10).
- B19+B29 [P2/P3] Mapbox token: no longer hard-coded fallback; reads env-only and shows a clear "Map unavailable — set VITE_MAPBOX_TOKEN" banner when missing.
- B23 [P3] PhotoCarousel suppresses "1/1" counter for single-photo listings; added onError fallback for broken URLs.
- B26 [P3] PhotoCarousel only enables loop when photos.length > 1.
- B27 [P3] TaskIndicator cancel/clear-all buttons gained aria-label + data-testid.
- B28 [P3] useTaskProgress strips terminal-local task IDs from the polling union — no more forever-poll on completed tasks.
## Tests
74 new vitest tests + 18 new pytest tests. Local: tsc clean, 201 vitest tests pass, 633 pytest tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0b5308200e
commit
a42944a756
46 changed files with 2260 additions and 238 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -55,7 +55,13 @@ RUN pytest tests/ -x -q
|
|||
# Stage 4: Final image — combine venv from builder + runtime base
|
||||
FROM runtime-base AS production
|
||||
|
||||
RUN adduser --system --no-create-home appuser
|
||||
# Create appuser with explicit UID 1000 / GID 1000 to match the NFS-backed
|
||||
# data PVC ownership (mode 775 dirs from older container builds were owned by
|
||||
# 1000:1000). Previously this used --system which assigned UID 100 / GID
|
||||
# 65534, causing PermissionError when the scraper tried to create new listing
|
||||
# directories on the NFS mount.
|
||||
RUN groupadd --gid 1000 appuser && \
|
||||
useradd --uid 1000 --gid 1000 --no-create-home --shell /usr/sbin/nologin appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -67,7 +73,7 @@ ENV PATH="/app/.venv/bin:$PATH"
|
|||
# Copy the application code
|
||||
COPY . .
|
||||
|
||||
RUN chown -R appuser /app
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
|
|
|
|||
10
api/app.py
10
api/app.py
|
|
@ -600,8 +600,14 @@ async def refresh_listings(
|
|||
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
|
||||
) -> dict[str, str]:
|
||||
"""Trigger a background task to refresh listings."""
|
||||
await send_notification(
|
||||
f"{user.email} refreshing listings with query parameters {query_parameters.model_dump_json()}"
|
||||
# Fire-and-forget the Slack notification so the API response isn't
|
||||
# blocked on the webhook round-trip (and so the no-op path when
|
||||
# SLACK_WEBHOOK_URL is unset doesn't add latency). send_notification
|
||||
# already catches its own exceptions so an orphaned task is harmless.
|
||||
asyncio.create_task(
|
||||
send_notification(
|
||||
f"{user.email} refreshing listings with query parameters {query_parameters.model_dump_json()}"
|
||||
)
|
||||
)
|
||||
|
||||
repository = ListingRepository(engine)
|
||||
|
|
|
|||
|
|
@ -18,12 +18,26 @@ app = Celery(
|
|||
include=["tasks.listing_tasks", "tasks.poi_tasks"],
|
||||
)
|
||||
|
||||
# Keep broker / result-backend connections alive when sitting behind an
|
||||
# HAProxy / load balancer that idles TCP connections (the in-cluster Redis
|
||||
# HAProxy reaps idle conns after 30s). Without these options the worker
|
||||
# logs a "Connection closed by server" every ~30s and progress publishes
|
||||
# silently drop on the closed socket.
|
||||
app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
broker_transport_options={
|
||||
"socket_keepalive": True,
|
||||
"health_check_interval": 25,
|
||||
},
|
||||
result_backend_transport_options={
|
||||
"socket_keepalive": True,
|
||||
"health_check_interval": 25,
|
||||
},
|
||||
broker_heartbeat=10,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import LoginModal from './components/LoginModal';
|
|||
import AuthCallback from './components/AuthCallback';
|
||||
import { Map } from './components/Map';
|
||||
import { type ParameterValues, DEFAULT_FILTER_VALUES, Metric, ListingType } from './components/FilterPanel';
|
||||
import { FilterBar } from './components/FilterBar';
|
||||
import { FilterBar, type FilterBarFormHandle } from './components/FilterBar';
|
||||
import { FilterChips } from './components/FilterChips';
|
||||
import { VisualizationCard } from './components/VisualizationCard';
|
||||
import { Header } from './components/Header';
|
||||
|
|
@ -69,13 +69,26 @@ function AppContent() {
|
|||
? { sub: 'dev-user', email: 'dev@localhost', name: 'Dev User', accessToken: 'dev-token', provider: 'passkey' as const }
|
||||
: null
|
||||
);
|
||||
// URL-derived filter state. filterValues mirrors the current URL; setFilterValues writes back.
|
||||
// Stable across re-renders for the chip/form reset paths.
|
||||
const { filterValues: urlFilterValues, setFilterValues: setUrlFilterValues, viewMode, setViewMode } = useFilterParams();
|
||||
|
||||
// Initial filter values read from URL on first mount (deep-link support). Captured once so the
|
||||
// form's defaultValues don't re-initialise on every URL change (RHF only honours mount-time defaults).
|
||||
const initialFilterValuesRef = useRef<ParameterValues>(urlFilterValues);
|
||||
|
||||
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(
|
||||
DEV_BYPASS_AUTH ? { ...DEFAULT_FILTER_VALUES, available_from: new Date() } : null
|
||||
DEV_BYPASS_AUTH ? urlFilterValues : null
|
||||
);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { viewMode, setViewMode } = useFilterParams();
|
||||
|
||||
// Form handle (from FilterBar) used to call form.reset() when chips/URL change.
|
||||
const filterBarFormRef = useRef<FilterBarFormHandle | null>(null);
|
||||
const handleFilterBarFormReady = useCallback((handle: FilterBarFormHandle) => {
|
||||
filterBarFormRef.current = handle;
|
||||
}, []);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
||||
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
||||
|
|
@ -88,8 +101,28 @@ function AppContent() {
|
|||
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
||||
} | null>(null);
|
||||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||
const [listingType, setListingType] = useState<ListingType>(DEFAULT_FILTER_VALUES.listing_type);
|
||||
const [currentMetric, setCurrentMetric] = useState<Metric>(urlFilterValues.metric);
|
||||
const [listingType, setListingTypeState] = useState<ListingType>(urlFilterValues.listing_type);
|
||||
// Wraps the listingType setter so any change (e.g. from the Header tab) also
|
||||
// propagates to the URL — this is the link that makes B2's URL-deep-link visible
|
||||
// in the address bar after a tab click.
|
||||
const setListingType = useCallback(
|
||||
(next: ListingType) => {
|
||||
setListingTypeState(next);
|
||||
// Only push to URL if the value actually changed (avoid no-op writes).
|
||||
// Note: we read the current URL state below; queryParameters may not exist yet
|
||||
// pre-first-load, in which case we fall back to urlFilterValues.
|
||||
setUrlFilterValues({
|
||||
...(urlFilterValues),
|
||||
listing_type: next,
|
||||
// Reset price defaults at the URL boundary so the form's price-defaults
|
||||
// effect can pick them up via initialValues / the urlFilterValues effect.
|
||||
min_price: next === ListingType.BUY ? 300000 : 2000,
|
||||
max_price: next === ListingType.BUY ? 600000 : 3000,
|
||||
});
|
||||
},
|
||||
[setUrlFilterValues, urlFilterValues],
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||
const [showReviewMode, setShowReviewMode] = useState(false);
|
||||
|
|
@ -158,6 +191,33 @@ function AppContent() {
|
|||
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
// Sync URL changes back into form on browser back/forward (B3).
|
||||
// We compare against queryParameters to avoid bouncing on our own setUrlFilterValues writes.
|
||||
useEffect(() => {
|
||||
if (!queryParameters) return;
|
||||
// Compare key fields (skip available_from since defaults always differ on re-construction)
|
||||
const same =
|
||||
urlFilterValues.listing_type === queryParameters.listing_type &&
|
||||
urlFilterValues.metric === queryParameters.metric &&
|
||||
urlFilterValues.min_price === queryParameters.min_price &&
|
||||
urlFilterValues.max_price === queryParameters.max_price &&
|
||||
urlFilterValues.min_bedrooms === queryParameters.min_bedrooms &&
|
||||
urlFilterValues.max_bedrooms === queryParameters.max_bedrooms &&
|
||||
urlFilterValues.min_sqm === queryParameters.min_sqm &&
|
||||
urlFilterValues.max_sqm === queryParameters.max_sqm &&
|
||||
urlFilterValues.last_seen_days === queryParameters.last_seen_days &&
|
||||
urlFilterValues.district === queryParameters.district;
|
||||
if (same) return;
|
||||
filterBarFormRef.current?.reset(urlFilterValues);
|
||||
if (urlFilterValues.listing_type !== listingType) {
|
||||
setListingType(urlFilterValues.listing_type);
|
||||
}
|
||||
setQueryParameters(urlFilterValues);
|
||||
loadListings(urlFilterValues);
|
||||
// intentionally omit loadListings from deps to avoid identity churn
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlFilterValues]);
|
||||
|
||||
// Load listings function - used by both auto-load and manual submit
|
||||
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
||||
if (!user) return;
|
||||
|
|
@ -347,12 +407,13 @@ function AppContent() {
|
|||
}
|
||||
initialLoadTriggeredRef.current = true;
|
||||
|
||||
const defaultParams: ParameterValues = {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
available_from: new Date(),
|
||||
// Use URL-derived filter values on initial auto-load so deep-links work (B3).
|
||||
const initialParams: ParameterValues = {
|
||||
...initialFilterValuesRef.current,
|
||||
available_from: initialFilterValuesRef.current.available_from ?? new Date(),
|
||||
};
|
||||
|
||||
loadListings(defaultParams);
|
||||
loadListings(initialParams);
|
||||
}, [user, loadListings]);
|
||||
|
||||
const handleTaskCompleted = useCallback(() => {
|
||||
|
|
@ -375,6 +436,12 @@ function AppContent() {
|
|||
}
|
||||
|
||||
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
||||
// Persist filter state to URL on every submit so reload + share-URL works (B3).
|
||||
setUrlFilterValues(parameters);
|
||||
// Reflect listing-type changes from the form into the parent state (single source of truth).
|
||||
if (parameters.listing_type !== listingType) {
|
||||
setListingType(parameters.listing_type);
|
||||
}
|
||||
if (action === 'visualize') {
|
||||
loadListings(parameters);
|
||||
} else if (action === 'fetch-data') {
|
||||
|
|
@ -409,7 +476,7 @@ function AppContent() {
|
|||
// Optionally: pan map to coordinates
|
||||
};
|
||||
|
||||
/** Handle removing a filter chip: reset the field to its default value and re-submit */
|
||||
/** Handle removing a filter chip: reset the field, sync form + URL, then re-submit (B8). */
|
||||
const handleRemoveChip = (key: keyof ParameterValues) => {
|
||||
if (!queryParameters) return;
|
||||
const updated = { ...queryParameters };
|
||||
|
|
@ -431,9 +498,26 @@ function AppContent() {
|
|||
default:
|
||||
(updated as Record<string, unknown>)[key] = (DEFAULT_FILTER_VALUES as Record<string, unknown>)[key];
|
||||
}
|
||||
// Reset the form so the popover inputs and the "More (N)" badge stay in sync (B8).
|
||||
filterBarFormRef.current?.reset(updated);
|
||||
setUrlFilterValues(updated);
|
||||
loadListings(updated);
|
||||
};
|
||||
|
||||
/** Reset every filter to defaults (B17). Clears URL, form, and re-loads. */
|
||||
const handleResetAllFilters = () => {
|
||||
const defaults: ParameterValues = {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
available_from: new Date(),
|
||||
};
|
||||
filterBarFormRef.current?.reset(defaults);
|
||||
setUrlFilterValues(defaults);
|
||||
if (defaults.listing_type !== listingType) {
|
||||
setListingType(defaults.listing_type);
|
||||
}
|
||||
loadListings(defaults);
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (!processedListingData) {
|
||||
return (
|
||||
|
|
@ -607,6 +691,7 @@ function AppContent() {
|
|||
userPOIs={userPOIs}
|
||||
poiTravelFilters={poiTravelFilters}
|
||||
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||
initialValues={initialFilterValuesRef.current}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 p-4">
|
||||
|
|
@ -687,24 +772,28 @@ function AppContent() {
|
|||
) : (
|
||||
/* Desktop layout: no sidebar, full-width main area */
|
||||
<>
|
||||
{/* Horizontal Filter Bar */}
|
||||
<FilterBar
|
||||
onSubmit={onSubmit}
|
||||
isLoading={isLoading}
|
||||
user={user}
|
||||
userPOIs={userPOIs}
|
||||
onPOIsChange={setUserPOIs}
|
||||
poiTravelFilters={poiTravelFilters}
|
||||
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||
listingType={listingType}
|
||||
onListingTypeChange={setListingType}
|
||||
poiPickerActive={poiPickerActive}
|
||||
onPoiPickerActiveChange={setPoiPickerActive}
|
||||
pickedPoiLocation={pickedPoiLocation}
|
||||
onPickedPoiLocationChange={setPickedPoiLocation}
|
||||
currentMetric={currentMetric}
|
||||
onTaskCreated={handlePOITaskCreated}
|
||||
/>
|
||||
{/* Horizontal Filter Bar with adjacent Review entry (B12) */}
|
||||
<div className="relative">
|
||||
<FilterBar
|
||||
onSubmit={onSubmit}
|
||||
isLoading={isLoading}
|
||||
user={user}
|
||||
userPOIs={userPOIs}
|
||||
onPOIsChange={setUserPOIs}
|
||||
poiTravelFilters={poiTravelFilters}
|
||||
onPoiTravelFiltersChange={setPoiTravelFilters}
|
||||
listingType={listingType}
|
||||
onListingTypeChange={setListingType}
|
||||
poiPickerActive={poiPickerActive}
|
||||
onPoiPickerActiveChange={setPoiPickerActive}
|
||||
pickedPoiLocation={pickedPoiLocation}
|
||||
onPickedPoiLocationChange={setPickedPoiLocation}
|
||||
currentMetric={currentMetric}
|
||||
onTaskCreated={handlePOITaskCreated}
|
||||
initialValues={initialFilterValuesRef.current}
|
||||
onFormReady={handleFilterBarFormReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Filter Chips */}
|
||||
{queryParameters && (
|
||||
|
|
@ -712,6 +801,7 @@ function AppContent() {
|
|||
values={queryParameters}
|
||||
defaults={{ ...DEFAULT_FILTER_VALUES, available_from: new Date() }}
|
||||
onRemove={handleRemoveChip}
|
||||
onResetAll={handleResetAllFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -727,8 +817,21 @@ function AppContent() {
|
|||
{/* Main content area (full width) */}
|
||||
<main className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{/* Map/List Container */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
<div className="flex-1 flex overflow-hidden min-h-0 relative">
|
||||
{renderMainContent()}
|
||||
{/* Desktop Swipe / Review entry point (B12) */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="absolute right-4 top-4 z-50 rounded-full shadow-lg h-12 w-12 bg-background"
|
||||
onClick={() => setShowReviewMode(true)}
|
||||
disabled={!processedListingData || processedListingData.features.length === 0}
|
||||
aria-label="Open swipe review"
|
||||
data-testid="desktop-review-fab"
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar with Metric Selector */}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,20 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
|
|||
};
|
||||
}
|
||||
|
||||
// Polyfill IntersectionObserver for jsdom (used by embla-carousel internally)
|
||||
if (typeof globalThis.IntersectionObserver === 'undefined') {
|
||||
// @ts-expect-error minimal jsdom stub
|
||||
globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
thresholds = [];
|
||||
};
|
||||
}
|
||||
|
||||
// Mock mapbox-gl (requires WebGL which jsdom doesn't support)
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from './FilterPanel';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { POI, POITravelFilter } from '@/types';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
// ── Zod schema (same as FilterPanel) ──
|
||||
const formSchema = z.object({
|
||||
|
|
@ -41,6 +42,13 @@ const formSchema = z.object({
|
|||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
// ── Props ──
|
||||
export interface FilterBarFormHandle {
|
||||
/** Reset the form to the given values (used by parent to mirror URL → form). */
|
||||
reset: (values: ParameterValues) => void;
|
||||
/** Direct access to the form (used by sibling components like FilterChips). */
|
||||
form: UseFormReturn<FormValues>;
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
onSubmit: (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => void;
|
||||
isLoading: boolean;
|
||||
|
|
@ -49,6 +57,7 @@ interface FilterBarProps {
|
|||
onPOIsChange: (pois: POI[]) => void;
|
||||
poiTravelFilters: Record<number, POITravelFilter>;
|
||||
onPoiTravelFiltersChange: (filters: Record<number, POITravelFilter>) => void;
|
||||
/** Controlled listing type (URL-driven). FilterBar reflects this value, never the inverse. */
|
||||
listingType: ListingType;
|
||||
onListingTypeChange: (type: ListingType) => void;
|
||||
poiPickerActive: boolean;
|
||||
|
|
@ -57,15 +66,50 @@ interface FilterBarProps {
|
|||
onPickedPoiLocationChange: (loc: { lat: number; lng: number } | null) => void;
|
||||
currentMetric: Metric;
|
||||
onTaskCreated?: (taskId: string) => void;
|
||||
/** Initial filter values (typically read from URL). Used once for defaultValues. */
|
||||
initialValues?: ParameterValues;
|
||||
/** Provides parent access to the form handle (e.g. for chip-remove resets). */
|
||||
onFormReady?: (handle: FilterBarFormHandle) => void;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function formatPrice(v: number): string {
|
||||
/**
|
||||
* Render a price as a compact label suitable for the chip-trigger button.
|
||||
* For values below 10,000 we keep one decimal so a small change like 1500 -> 2000
|
||||
* doesn't visually collide with the next-thousand value (e.g. 2k).
|
||||
* Sub-1k values render as the raw integer.
|
||||
*/
|
||||
export function formatPrice(v: number): string {
|
||||
if (v >= 1_000_000) return `\u00A3${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (v >= 1_000) return `\u00A3${(v / 1_000).toFixed(0)}k`;
|
||||
if (v >= 10_000) return `\u00A3${(v / 1_000).toFixed(0)}k`;
|
||||
if (v >= 1_000) {
|
||||
const k = v / 1_000;
|
||||
// Drop trailing .0 (so 2000 stays "2k", not "2.0k")
|
||||
const label = Number.isInteger(k) ? `${k}` : k.toFixed(1);
|
||||
return `\u00A3${label}k`;
|
||||
}
|
||||
return `\u00A3${v}`;
|
||||
}
|
||||
|
||||
/** Convert a ParameterValues to FormValues. ParameterValues may omit available_from; default to now. */
|
||||
function toFormValues(values: ParameterValues): FormValues {
|
||||
return {
|
||||
listing_type: values.listing_type,
|
||||
min_bedrooms: values.min_bedrooms,
|
||||
max_bedrooms: values.max_bedrooms,
|
||||
min_price: values.min_price,
|
||||
max_price: values.max_price,
|
||||
min_sqm: values.min_sqm,
|
||||
max_sqm: values.max_sqm,
|
||||
min_price_per_sqm: values.min_price_per_sqm,
|
||||
max_price_per_sqm: values.max_price_per_sqm,
|
||||
last_seen_days: values.last_seen_days,
|
||||
available_from: values.available_from ?? new Date(),
|
||||
district: values.district ?? '',
|
||||
furnish_types: values.furnish_types,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read current ParameterValues from the form state (merges metric and furnish) */
|
||||
function readFormParams(
|
||||
values: FormValues,
|
||||
|
|
@ -94,57 +138,84 @@ export function FilterBar({
|
|||
onPickedPoiLocationChange,
|
||||
currentMetric,
|
||||
onTaskCreated,
|
||||
initialValues,
|
||||
onFormReady,
|
||||
}: FilterBarProps) {
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>(
|
||||
initialValues?.furnish_types ?? [],
|
||||
);
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState('now');
|
||||
|
||||
// Compute defaultValues ONCE from initialValues (or DEFAULT_FILTER_VALUES). React-Hook-Form
|
||||
// does not re-read defaultValues after mount — use form.reset() for runtime updates.
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
listing_type: DEFAULT_FILTER_VALUES.listing_type,
|
||||
min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
|
||||
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
|
||||
min_price: DEFAULT_FILTER_VALUES.min_price,
|
||||
max_price: DEFAULT_FILTER_VALUES.max_price,
|
||||
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
|
||||
max_sqm: undefined,
|
||||
min_price_per_sqm: undefined,
|
||||
max_price_per_sqm: undefined,
|
||||
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
|
||||
available_from: new Date(),
|
||||
district: '',
|
||||
},
|
||||
defaultValues: initialValues
|
||||
? toFormValues(initialValues)
|
||||
: {
|
||||
listing_type: DEFAULT_FILTER_VALUES.listing_type,
|
||||
min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
|
||||
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
|
||||
min_price: DEFAULT_FILTER_VALUES.min_price,
|
||||
max_price: DEFAULT_FILTER_VALUES.max_price,
|
||||
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
|
||||
max_sqm: undefined,
|
||||
min_price_per_sqm: undefined,
|
||||
max_price_per_sqm: undefined,
|
||||
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
|
||||
available_from: new Date(),
|
||||
district: '',
|
||||
},
|
||||
});
|
||||
|
||||
const watchedListingType = form.watch('listing_type');
|
||||
// ── Single source of truth for listing type ──
|
||||
// The PARENT owns listingType; FilterBar mirrors it one-way.
|
||||
// The header's BUY/RENT tabs call onListingTypeChange (parent setter), which causes a re-render
|
||||
// with the new prop. We then push it into the form via the prop-driven effect below.
|
||||
//
|
||||
// We deliberately do NOT have a `form → parent` sync effect; instead any in-form control that
|
||||
// wants to change listing_type calls onListingTypeChange directly (no internal listing_type tabs
|
||||
// exist in FilterBar). This breaks the ping-pong loop that caused B2.
|
||||
const previousListingTypeRef = useRef<ListingType>(form.getValues('listing_type'));
|
||||
|
||||
// Sync listing type with parent
|
||||
useEffect(() => {
|
||||
if (watchedListingType !== listingType) {
|
||||
onListingTypeChange(watchedListingType);
|
||||
}
|
||||
}, [watchedListingType, listingType, onListingTypeChange]);
|
||||
if (form.getValues('listing_type') === listingType) return;
|
||||
|
||||
// Sync parent listing type changes back into form
|
||||
useEffect(() => {
|
||||
if (listingType !== form.getValues('listing_type')) {
|
||||
form.setValue('listing_type', listingType);
|
||||
// Reflect parent listing type in the form.
|
||||
form.setValue('listing_type', listingType, { shouldDirty: false });
|
||||
|
||||
// On a real transition (not the mount-time no-op above), reset price defaults
|
||||
// and clear furnish (BUY only). This runs at most once per listing-type change.
|
||||
if (previousListingTypeRef.current !== listingType) {
|
||||
if (listingType === ListingType.BUY) {
|
||||
form.setValue('min_price', 300000, { shouldDirty: false });
|
||||
form.setValue('max_price', 600000, { shouldDirty: false });
|
||||
setSelectedFurnishTypes([]);
|
||||
} else {
|
||||
form.setValue('min_price', 2000, { shouldDirty: false });
|
||||
form.setValue('max_price', 3000, { shouldDirty: false });
|
||||
}
|
||||
previousListingTypeRef.current = listingType;
|
||||
}
|
||||
}, [listingType, form]);
|
||||
|
||||
// Price defaults when listing type changes
|
||||
// Expose form handle to parent for chip-remove resets etc.
|
||||
useEffect(() => {
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
form.setValue('min_price', 300000);
|
||||
form.setValue('max_price', 600000);
|
||||
} else {
|
||||
form.setValue('min_price', 2000);
|
||||
form.setValue('max_price', 3000);
|
||||
}
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
setSelectedFurnishTypes([]);
|
||||
}
|
||||
}, [watchedListingType, form]);
|
||||
if (!onFormReady) return;
|
||||
onFormReady({
|
||||
reset: (values: ParameterValues) => {
|
||||
form.reset(toFormValues(values));
|
||||
setSelectedFurnishTypes(values.furnish_types ?? []);
|
||||
// Keep the local transition guard in sync so the next listingType prop change
|
||||
// doesn't trigger the price-defaults branch erroneously.
|
||||
previousListingTypeRef.current = values.listing_type;
|
||||
},
|
||||
form,
|
||||
});
|
||||
}, [form, onFormReady]);
|
||||
|
||||
// `watchedListingType` is still used to drive conditional UI (e.g. furnish section, POI manager).
|
||||
const watchedListingType = form.watch('listing_type');
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(action: 'fetch-data' | 'visualize') => {
|
||||
|
|
@ -417,6 +488,7 @@ export function FilterBar({
|
|||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="28"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface FilterChipsProps {
|
|||
values: ParameterValues;
|
||||
defaults: ParameterValues;
|
||||
onRemove: (key: keyof ParameterValues) => void;
|
||||
/** Optional handler for the "Reset all" affordance — clears every active filter to defaults. */
|
||||
onResetAll?: () => void;
|
||||
}
|
||||
|
||||
/** Format a price value for display */
|
||||
|
|
@ -98,13 +100,13 @@ function buildChips(values: ParameterValues, defaults: ParameterValues): ChipDef
|
|||
return chips;
|
||||
}
|
||||
|
||||
export function FilterChips({ values, defaults, onRemove }: FilterChipsProps) {
|
||||
export function FilterChips({ values, defaults, onRemove, onResetAll }: FilterChipsProps) {
|
||||
const chips = buildChips(values, defaults);
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 py-1.5 border-b bg-muted/20">
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-4 py-1.5 border-b bg-muted/20">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
|
|
@ -121,6 +123,16 @@ export function FilterChips({ values, defaults, onRemove }: FilterChipsProps) {
|
|||
</button>
|
||||
</span>
|
||||
))}
|
||||
{onResetAll && chips.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 px-1.5 py-0.5"
|
||||
aria-label="Reset all filters"
|
||||
>
|
||||
Reset all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "./ui/button";
|
||||
|
|
@ -82,6 +82,8 @@ interface FilterPanelProps {
|
|||
userPOIs?: POI[];
|
||||
poiTravelFilters?: Record<number, POITravelFilter>;
|
||||
onPoiTravelFiltersChange?: (filters: Record<number, POITravelFilter>) => void;
|
||||
/** Initial filter values (typically read from URL). Used once for defaultValues. */
|
||||
initialValues?: ParameterValues;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
|
|
@ -110,45 +112,64 @@ const PRICE_BOUNDS = {
|
|||
|
||||
const BEDROOM_BOUNDS = { min: 0, max: 10, step: 1 } as const;
|
||||
|
||||
export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, poiTravelFilters, onPoiTravelFiltersChange }: FilterPanelProps) {
|
||||
export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, poiTravelFilters, onPoiTravelFiltersChange, initialValues }: FilterPanelProps) {
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>(
|
||||
initialValues?.furnish_types ?? [],
|
||||
);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
listing_type: DEFAULT_FILTER_VALUES.listing_type,
|
||||
min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
|
||||
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
|
||||
min_price: DEFAULT_FILTER_VALUES.min_price,
|
||||
max_price: DEFAULT_FILTER_VALUES.max_price,
|
||||
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
|
||||
max_sqm: undefined,
|
||||
min_price_per_sqm: undefined,
|
||||
max_price_per_sqm: undefined,
|
||||
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
|
||||
available_from: new Date(),
|
||||
district: '',
|
||||
},
|
||||
defaultValues: initialValues
|
||||
? {
|
||||
listing_type: initialValues.listing_type,
|
||||
min_bedrooms: initialValues.min_bedrooms,
|
||||
max_bedrooms: initialValues.max_bedrooms,
|
||||
min_price: initialValues.min_price,
|
||||
max_price: initialValues.max_price,
|
||||
min_sqm: initialValues.min_sqm,
|
||||
max_sqm: initialValues.max_sqm,
|
||||
min_price_per_sqm: initialValues.min_price_per_sqm,
|
||||
max_price_per_sqm: initialValues.max_price_per_sqm,
|
||||
last_seen_days: initialValues.last_seen_days,
|
||||
available_from: initialValues.available_from ?? new Date(),
|
||||
district: initialValues.district ?? '',
|
||||
furnish_types: initialValues.furnish_types,
|
||||
}
|
||||
: {
|
||||
listing_type: DEFAULT_FILTER_VALUES.listing_type,
|
||||
min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms,
|
||||
max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms,
|
||||
min_price: DEFAULT_FILTER_VALUES.min_price,
|
||||
max_price: DEFAULT_FILTER_VALUES.max_price,
|
||||
min_sqm: DEFAULT_FILTER_VALUES.min_sqm,
|
||||
max_sqm: undefined,
|
||||
min_price_per_sqm: undefined,
|
||||
max_price_per_sqm: undefined,
|
||||
last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days,
|
||||
available_from: new Date(),
|
||||
district: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch listing_type to make filters type-aware
|
||||
const watchedListingType = form.watch('listing_type');
|
||||
const priceBounds = PRICE_BOUNDS[watchedListingType];
|
||||
|
||||
// Update price defaults when listing type changes
|
||||
// Update price defaults ONLY on real listing-type transitions, not on every render.
|
||||
// Use a ref to detect actual changes (mount-time render is a no-op).
|
||||
const previousListingTypeRef = useRef<ListingType>(watchedListingType);
|
||||
useEffect(() => {
|
||||
if (watchedListingType === previousListingTypeRef.current) return;
|
||||
previousListingTypeRef.current = watchedListingType;
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
form.setValue('min_price', 300000);
|
||||
form.setValue('max_price', 600000);
|
||||
setSelectedFurnishTypes([]);
|
||||
} else {
|
||||
form.setValue('min_price', 2000);
|
||||
form.setValue('max_price', 3000);
|
||||
}
|
||||
// Clear furnish types when switching to BUY
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
setSelectedFurnishTypes([]);
|
||||
}
|
||||
}, [watchedListingType, form]);
|
||||
|
||||
const handleFormSubmit = (action: 'fetch-data' | 'visualize') => {
|
||||
|
|
@ -451,6 +472,7 @@ export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount,
|
|||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="28"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
|
|
|
|||
|
|
@ -156,11 +156,15 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Agency */}
|
||||
{/* Agency — wrapped in a labelled block so the Overview tab doesn't
|
||||
collapse to just "Foxtons" when description/key_features/floorplans are empty. */}
|
||||
{detail.agency && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Building className="h-4 w-4" />
|
||||
<span>{detail.agency}</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Listed by</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Building className="h-4 w-4" />
|
||||
<span>{detail.agency}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export function ListingDetailSheet({
|
|||
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
|
||||
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh] sm:!max-w-2xl sm:mx-auto">
|
||||
<Drawer.Title className="sr-only">Listing Details</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">
|
||||
Property details including price, location, photos, travel times, and price history.
|
||||
</Drawer.Description>
|
||||
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && (
|
||||
|
|
|
|||
|
|
@ -85,11 +85,18 @@ export function Map(props: MapProps) {
|
|||
// Pass all features to the heatmap — filtering is done server-side
|
||||
heatmap.setData(data);
|
||||
|
||||
// Compute color scale in worker (sorts + percentiles off main thread)
|
||||
// Compute color scale in worker (sorts + percentiles off main thread).
|
||||
// If the client is torn down mid-flight (rapid view-mode switch), the worker
|
||||
// promise rejects with "HexgridHeatmapClient destroyed" — silently swallow
|
||||
// that case rather than letting it surface as a pageerror (Bug B11).
|
||||
const colorResult = await heatmap.computeColorScale(metricMode, {
|
||||
minBound: PERCENTILE_CONFIG.MIN_BOUND,
|
||||
maxBound: PERCENTILE_CONFIG.MAX_BOUND,
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
// If the heatmap was destroyed (different ref) or the promise was cancelled,
|
||||
// bail out — the component is unmounting or recreating.
|
||||
if (!colorResult || heatmapRef.current !== heatmap) return;
|
||||
|
||||
if (colorResult.hasValues) {
|
||||
makeLegend(colorScheme, colorResult.min, colorResult.max);
|
||||
|
|
@ -111,20 +118,28 @@ export function Map(props: MapProps) {
|
|||
const boundsResult = await heatmap.computeBounds({
|
||||
clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN,
|
||||
clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX,
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
mapRef.current?.fitBounds([
|
||||
[boundsResult.minLng, boundsResult.minLat],
|
||||
[boundsResult.maxLng, boundsResult.maxLat]
|
||||
], { duration: 0 });
|
||||
if (boundsResult && heatmapRef.current === heatmap) {
|
||||
mapRef.current?.fitBounds([
|
||||
[boundsResult.minLng, boundsResult.minLat],
|
||||
[boundsResult.maxLng, boundsResult.maxLat]
|
||||
], { duration: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
lastDataLengthRef.current = data.features.length;
|
||||
}, [data, metricMode, colorScheme]);
|
||||
|
||||
// Track whether the Mapbox token is configured. When missing we render a banner
|
||||
// inside the map container instead of letting Mapbox 404 on the style request
|
||||
// (B19/B29).
|
||||
const isMapboxTokenMissing = !MAP_CONFIG.MAPBOX_TOKEN;
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
if (isMapboxTokenMissing) return;
|
||||
|
||||
mapboxgl.accessToken = MAP_CONFIG.MAPBOX_TOKEN;
|
||||
mapRef.current = new mapboxgl.Map({
|
||||
|
|
@ -199,7 +214,7 @@ export function Map(props: MapProps) {
|
|||
mapRef.current?.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [isMapboxTokenMissing]);
|
||||
|
||||
// Debounced update effect - only update after 200ms of no changes
|
||||
useEffect(() => {
|
||||
|
|
@ -417,6 +432,15 @@ export function Map(props: MapProps) {
|
|||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div id='map-container' ref={mapContainerRef}></div>
|
||||
{isMapboxTokenMissing && (
|
||||
<div
|
||||
role="alert"
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-muted/95 text-muted-foreground text-sm font-medium p-4 text-center"
|
||||
data-testid="mapbox-token-missing-banner"
|
||||
>
|
||||
Map unavailable — set <code className="font-mono text-xs bg-background px-1 py-0.5 rounded">VITE_MAPBOX_TOKEN</code> to enable the basemap.
|
||||
</div>
|
||||
)}
|
||||
{props.isPickingPOI && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium">
|
||||
<Crosshair className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ export function MobileBottomSheet({
|
|||
style={{ maxHeight: '85vh' }}
|
||||
>
|
||||
<Drawer.Title className="sr-only">Property Listings</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">
|
||||
Swipeable list of matching properties. Drag the handle to expand or collapse the sheet.
|
||||
</Drawer.Description>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
|
||||
import type { ListingDetailPhoto } from '@/types';
|
||||
|
||||
interface PhotoCarouselProps {
|
||||
|
|
@ -8,8 +8,13 @@ interface PhotoCarouselProps {
|
|||
}
|
||||
|
||||
export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
// Only enable Embla loop when there are multiple photos. Looping on a single
|
||||
// image lets the user drag it off-screen and reveal it sliding back (B26).
|
||||
const hasMultiple = photos.length > 1;
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: hasMultiple });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
// Track which photo URLs failed so we can swap them for a placeholder (B23).
|
||||
const [brokenIndexes, setBrokenIndexes] = useState<Set<number>>(() => new Set());
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
|
|
@ -22,6 +27,39 @@ export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
return () => { emblaApi.off('select', onSelect); };
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
// Keyboard navigation: ArrowLeft / ArrowRight advance the active slide (B16).
|
||||
// Embla doesn't ship a built-in keyboard plugin in this repo's deps, so we
|
||||
// attach a focus-scoped listener instead of registering a global window key.
|
||||
useEffect(() => {
|
||||
if (!emblaApi || !hasMultiple) return;
|
||||
const root = emblaApi.rootNode();
|
||||
if (!root) return;
|
||||
// Make the carousel focusable so it can receive key events.
|
||||
if (root.tabIndex === -1) {
|
||||
root.tabIndex = 0;
|
||||
}
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
emblaApi.scrollPrev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
emblaApi.scrollNext();
|
||||
}
|
||||
};
|
||||
root.addEventListener('keydown', handleKey);
|
||||
return () => { root.removeEventListener('keydown', handleKey); };
|
||||
}, [emblaApi, hasMultiple]);
|
||||
|
||||
const handleImgError = useCallback((i: number) => {
|
||||
setBrokenIndexes((prev) => {
|
||||
if (prev.has(i)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(i);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-48 bg-muted flex items-center justify-center text-muted-foreground">
|
||||
|
|
@ -32,22 +70,35 @@ export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div
|
||||
className="overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
ref={emblaRef}
|
||||
aria-label="Property photos"
|
||||
role="region"
|
||||
>
|
||||
<div className="flex">
|
||||
{photos.map((photo, i) => (
|
||||
<div key={i} className="flex-[0_0_100%] min-w-0">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || `Photo ${i + 1}`}
|
||||
className="w-full h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{brokenIndexes.has(i) ? (
|
||||
<div className="w-full h-64 bg-muted flex flex-col items-center justify-center text-muted-foreground gap-1">
|
||||
<ImageOff className="w-8 h-8" />
|
||||
<span className="text-xs">Photo unavailable</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || `Photo ${i + 1}`}
|
||||
className="w-full h-64 object-cover"
|
||||
loading="lazy"
|
||||
onError={() => handleImgError(i)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Prev/Next arrows */}
|
||||
{photos.length > 1 && (
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-1 transition-colors"
|
||||
|
|
@ -65,12 +116,14 @@ export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Counter */}
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||
{selectedIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{/* Counter (suppressed when there's only one photo — B23) */}
|
||||
{hasMultiple && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||
{selectedIndex + 1} / {photos.length}
|
||||
</div>
|
||||
)}
|
||||
{/* Dots */}
|
||||
{photos.length > 1 && photos.length <= 20 && (
|
||||
{hasMultiple && photos.length <= 20 && (
|
||||
<div className="flex justify-center gap-1 mt-2">
|
||||
{photos.map((_, i) => (
|
||||
<button
|
||||
|
|
@ -79,6 +132,7 @@ export function PhotoCarousel({ photos }: PhotoCarouselProps) {
|
|||
i === selectedIndex ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
onClick={() => emblaApi?.scrollTo(i)}
|
||||
aria-label={`Go to photo ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
|||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { ExternalLink, Footprints, Bike, Train } from 'lucide-react';
|
||||
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
||||
import { formatDuration } from '@/utils/format';
|
||||
import { formatDuration, formatPrice, formatInteger, formatPricePerSqmShort, isFiniteNumber, EM_DASH } from '@/utils/format';
|
||||
|
||||
function TravelModeIcon({ mode }: { mode: string }) {
|
||||
switch (mode) {
|
||||
|
|
@ -13,15 +13,16 @@ function TravelModeIcon({ mode }: { mode: string }) {
|
|||
}
|
||||
}
|
||||
|
||||
const TRAVEL_MODES: Array<'WALK' | 'BICYCLE' | 'TRANSIT'> = ['WALK', 'BICYCLE', 'TRANSIT'];
|
||||
|
||||
function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
||||
if (!distances || distances.length === 0) return null;
|
||||
|
||||
// Group by POI name
|
||||
const byPoi = new Map<string, POIDistanceInfo[]>();
|
||||
// Group by POI name, indexing by travel_mode for consistent rendering.
|
||||
const byPoi = new Map<string, Map<string, POIDistanceInfo>>();
|
||||
for (const d of distances) {
|
||||
const existing = byPoi.get(d.poi_name) || [];
|
||||
existing.push(d);
|
||||
byPoi.set(d.poi_name, existing);
|
||||
if (!byPoi.has(d.poi_name)) byPoi.set(d.poi_name, new Map());
|
||||
byPoi.get(d.poi_name)!.set(d.travel_mode, d);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -29,20 +30,21 @@ function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
|
|||
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
|
||||
<div key={poiName} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{poiName}:</span>
|
||||
{dists.map(d => (
|
||||
<span key={d.travel_mode} className="inline-flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
|
||||
<TravelModeIcon mode={d.travel_mode} />
|
||||
{formatDuration(d.duration_seconds)}
|
||||
</span>
|
||||
))}
|
||||
{TRAVEL_MODES.map(mode => {
|
||||
const d = dists.get(mode);
|
||||
return (
|
||||
<span key={mode} className="inline-flex items-center gap-0.5" title={`${mode} to ${poiName}`}>
|
||||
<TravelModeIcon mode={mode} />
|
||||
{d ? formatDuration(d.duration_seconds) : EM_DASH}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TRAVEL_MODES: Array<'WALK' | 'BICYCLE' | 'TRANSIT'> = ['WALK', 'BICYCLE', 'TRANSIT'];
|
||||
|
||||
function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDistanceInfo[] }) {
|
||||
// Index distances by poi_id + travel_mode for O(1) lookup
|
||||
const distMap = new Map<string, POIDistanceInfo>();
|
||||
|
|
@ -73,7 +75,10 @@ function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDist
|
|||
}
|
||||
|
||||
function CardCarousel({ photos, altText }: { photos: string[]; altText?: string }) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
||||
// Only loop when there's more than one image (single-image carousels should
|
||||
// be static — mirrors PhotoCarousel B26).
|
||||
const hasMultiple = photos.length > 1;
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: hasMultiple });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
|
|
@ -87,7 +92,29 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
|||
return () => { emblaApi.off('select', onSelect); };
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
if (photos.length <= 1) {
|
||||
// Keyboard nav for the card carousel (B16). Listener is scoped to the
|
||||
// embla root so it only fires when the user focuses this carousel.
|
||||
useEffect(() => {
|
||||
if (!emblaApi || !hasMultiple) return;
|
||||
const root = emblaApi.rootNode();
|
||||
if (!root) return;
|
||||
if (root.tabIndex === -1) {
|
||||
root.tabIndex = 0;
|
||||
}
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
emblaApi.scrollPrev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
emblaApi.scrollNext();
|
||||
}
|
||||
};
|
||||
root.addEventListener('keydown', handleKey);
|
||||
return () => { root.removeEventListener('keydown', handleKey); };
|
||||
}, [emblaApi, hasMultiple]);
|
||||
|
||||
if (!hasMultiple) {
|
||||
return (
|
||||
<img
|
||||
src={photos[0]}
|
||||
|
|
@ -100,7 +127,12 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
|||
|
||||
return (
|
||||
<div className="relative w-full h-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div
|
||||
className="overflow-hidden h-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
ref={emblaRef}
|
||||
aria-label="Property photos"
|
||||
role="region"
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{photos.map((url, i) => (
|
||||
<div key={i} className="flex-[0_0_100%] min-w-0 h-full">
|
||||
|
|
@ -145,22 +177,28 @@ export function PropertyCard({
|
|||
allPOIs,
|
||||
onClick,
|
||||
}: PropertyCardProps) {
|
||||
// BUY listings may have null numeric / date fields; coerce so renders don't throw.
|
||||
// BUY listings may have null numeric / date fields; render "—" at the JSX leaf
|
||||
// when the source is null/undefined/non-finite so the user can't mistake a missing
|
||||
// value for a real £0 / 0 m².
|
||||
const lastSeenRaw = property.last_seen;
|
||||
const lastSeenDate = typeof lastSeenRaw === 'string' ? lastSeenRaw.split('T')[0] : null;
|
||||
const lastSeenTime = lastSeenDate ? new Date(lastSeenDate).getTime() : NaN;
|
||||
const lastSeenDays = Number.isFinite(lastSeenTime)
|
||||
const lastSeenDaysRaw = Number.isFinite(lastSeenTime)
|
||||
? Math.round((Date.now() - lastSeenTime) / (1000 * 60 * 60 * 24))
|
||||
: null;
|
||||
// Clamp future timestamps to 0 so we don't render "-7d ago" for stale BUY rows.
|
||||
const lastSeenDays = lastSeenDaysRaw !== null ? Math.max(0, lastSeenDaysRaw) : null;
|
||||
// Coerced numerics used only where a number is structurally required (alt text,
|
||||
// boolean comparisons). All visible numeric leaves use the format helpers.
|
||||
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
const safeTotalPrice = safeNum(property.total_price);
|
||||
const safeQm = safeNum(property.qm);
|
||||
const safeQmprice = safeNum(property.qmprice);
|
||||
const safeRooms = safeNum(property.rooms);
|
||||
|
||||
// Determine if this is a good deal
|
||||
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
||||
// Determine if this is a good deal (guard requires a finite qmprice > 0)
|
||||
const qmpriceForCompare = isFiniteNumber(property.qmprice) ? property.qmprice : null;
|
||||
const isGoodDeal = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > 0 && qmpriceForCompare < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > avgPricePerSqm * 1.1;
|
||||
|
||||
const priceIndicator = isGoodDeal
|
||||
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
||||
|
|
@ -195,8 +233,8 @@ export function PropertyCard({
|
|||
{/* Price */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
£{safeTotalPrice.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
{formatPrice(property.total_price)}
|
||||
{property.listing_type !== 'BUY' && isFiniteNumber(property.total_price) && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -210,11 +248,11 @@ export function PropertyCard({
|
|||
|
||||
{/* Key metrics on one line */}
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
|
||||
<span>{safeRooms}</span><span>bed</span>
|
||||
<span>{formatInteger(property.rooms)}</span><span>bed</span>
|
||||
<span>·</span>
|
||||
<span>{safeQm} m²</span>
|
||||
<span>{formatInteger(property.qm)} m²</span>
|
||||
<span>·</span>
|
||||
<span>£{safeQmprice}/m²</span>
|
||||
<span>{formatPricePerSqmShort(property.qmprice)}</span>
|
||||
</div>
|
||||
|
||||
{/* Agency + freshness */}
|
||||
|
|
@ -271,8 +309,8 @@ export function PropertyCard({
|
|||
{/* Price as dominant element */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
£{safeTotalPrice.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
{formatPrice(property.total_price)}
|
||||
{property.listing_type !== 'BUY' && isFiniteNumber(property.total_price) && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -286,11 +324,11 @@ export function PropertyCard({
|
|||
|
||||
{/* Key metrics on one line */}
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>{safeRooms}</span><span>bed</span>
|
||||
<span>{formatInteger(property.rooms)}</span><span>bed</span>
|
||||
<span>·</span>
|
||||
<span>{safeQm} m²</span>
|
||||
<span>{formatInteger(property.qm)} m²</span>
|
||||
<span>·</span>
|
||||
<span>£{safeQmprice}/m²</span>
|
||||
<span>{formatPricePerSqmShort(property.qmprice)}</span>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Bed, MapPin } from 'lucide-react';
|
||||
import type { PropertyProperties } from '@/types';
|
||||
import { formatPrice, formatInteger, isFiniteNumber } from '@/utils/format';
|
||||
|
||||
interface PropertyCardCompactProps {
|
||||
property: PropertyProperties;
|
||||
|
|
@ -16,15 +17,15 @@ export function PropertyCardCompact({
|
|||
avgPricePerSqm,
|
||||
onClick,
|
||||
}: PropertyCardCompactProps) {
|
||||
// BUY listings may have null numeric fields; coerce so renders don't throw.
|
||||
// BUY listings may have null numeric fields; render "—" at the JSX leaf for
|
||||
// missing values rather than coercing to 0 (which renders as "£0 / 0 m²").
|
||||
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
const safeTotalPrice = safeNum(property.total_price);
|
||||
const safeQm = safeNum(property.qm);
|
||||
const safeQmprice = safeNum(property.qmprice);
|
||||
const safeRooms = safeNum(property.rooms);
|
||||
|
||||
const isGoodDeal = avgPricePerSqm && safeQmprice > 0 && safeQmprice < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && safeQmprice > avgPricePerSqm * 1.1;
|
||||
const qmpriceForCompare = isFiniteNumber(property.qmprice) ? property.qmprice : null;
|
||||
const isGoodDeal = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > 0 && qmpriceForCompare < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && qmpriceForCompare !== null && qmpriceForCompare > avgPricePerSqm * 1.1;
|
||||
|
||||
const priceIndicator = isGoodDeal
|
||||
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
||||
|
|
@ -55,8 +56,8 @@ export function PropertyCardCompact({
|
|||
{/* Price bold */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-base">
|
||||
£{safeTotalPrice.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
{formatPrice(property.total_price)}
|
||||
{property.listing_type !== 'BUY' && isFiniteNumber(property.total_price) && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -69,10 +70,10 @@ export function PropertyCardCompact({
|
|||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bed className="h-3.5 w-3.5" />
|
||||
{safeRooms} bed
|
||||
{formatInteger(property.rooms)} bed
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{safeQm} m²</span>
|
||||
<span>{formatInteger(property.qm)} m²</span>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useDrag } from '@use-gesture/react';
|
|||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { Bed, Maximize2, ExternalLink, ChevronLeft, ChevronRight, Building2, Calendar } from 'lucide-react';
|
||||
import type { PropertyFeature } from '@/types';
|
||||
import { formatPrice, formatInteger, formatPricePerSqmShort, formatDuration, isFiniteNumber } from '@/utils/format';
|
||||
|
||||
interface SwipeCardProps {
|
||||
feature: PropertyFeature;
|
||||
|
|
@ -19,11 +20,12 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
const hasSwiped = useRef(false);
|
||||
const p = feature.properties;
|
||||
const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : [];
|
||||
// BUY listings may have null numeric fields; coerce so renders don't throw.
|
||||
// BUY listings may have null numeric fields; render "—" at the JSX leaf for
|
||||
// missing values rather than coercing to 0. safeNum-coerced values are still used
|
||||
// for alt text (where rendering "£0" is acceptable for non-visible content).
|
||||
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
const safeTotalPrice = safeNum(p.total_price);
|
||||
const safeQm = safeNum(p.qm);
|
||||
const safeQmprice = safeNum(p.qmprice);
|
||||
const safeRooms = safeNum(p.rooms);
|
||||
|
||||
const prefersReducedMotion = useMemo(
|
||||
|
|
@ -177,8 +179,8 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
>
|
||||
{/* Price */}
|
||||
<div className="text-2xl font-semibold">
|
||||
£{safeTotalPrice.toLocaleString()}
|
||||
{p.listing_type !== 'BUY' && (
|
||||
{formatPrice(p.total_price)}
|
||||
{p.listing_type !== 'BUY' && isFiniteNumber(p.total_price) && (
|
||||
<span className="text-muted-foreground font-normal text-base">/mo</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -186,12 +188,12 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
{/* Key stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bed className="h-4 w-4" /> {safeRooms} bed
|
||||
<Bed className="h-4 w-4" /> {formatInteger(p.rooms)} bed
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Maximize2 className="h-4 w-4" /> {safeQm} m²
|
||||
<Maximize2 className="h-4 w-4" /> {formatInteger(p.qm)} m²
|
||||
</span>
|
||||
<span>£{safeQmprice}/m²</span>
|
||||
<span>{formatPricePerSqmShort(p.qmprice)}</span>
|
||||
</div>
|
||||
|
||||
{/* Agency & availability */}
|
||||
|
|
@ -216,7 +218,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
|
|||
key={`${d.poi_id}_${d.travel_mode}`}
|
||||
className="text-xs bg-muted px-2 py-0.5 rounded"
|
||||
>
|
||||
{d.poi_name}: {Math.round(d.duration_seconds / 60)}m
|
||||
{d.poi_name}: {formatDuration(d.duration_seconds)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -194,9 +194,11 @@ export function TaskIndicator({
|
|||
size="icon"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
aria-label="Cancel task"
|
||||
data-testid="task-cancel-button"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-3 w-3 lucide-x" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
|
|
@ -211,9 +213,11 @@ export function TaskIndicator({
|
|||
size="icon"
|
||||
onClick={handleClearAll}
|
||||
disabled={isClearing}
|
||||
aria-label="Clear all tasks"
|
||||
data-testid="task-clear-all-button"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
|
|
|
|||
182
frontend/src/components/__tests__/FilterBar.test.tsx
Normal file
182
frontend/src/components/__tests__/FilterBar.test.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Regression tests for FilterBar (B2, B3, B22).
|
||||
*
|
||||
* B2: clicking BUY in the parent (header tab) MUST NOT cause a "Maximum update depth
|
||||
* exceeded" loop. The previous implementation had three mutually-triggering
|
||||
* useEffects (form ↔ parent sync + price defaults) that ping-ponged on every render.
|
||||
*
|
||||
* B3: FilterBar accepts initialValues (URL-derived) as its form defaults so deep-link
|
||||
* URLs are honoured. The parent feeds these in from useFilterParams().
|
||||
*
|
||||
* B22: formatPrice() must visually distinguish 1500 from 2000 — the previous
|
||||
* toFixed(0) collapsed both to "£2k", hiding any sub-default change.
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { FilterBar, formatPrice } from '@/components/FilterBar';
|
||||
import {
|
||||
ListingType,
|
||||
Metric,
|
||||
type ParameterValues,
|
||||
DEFAULT_FILTER_VALUES,
|
||||
} from '@/components/FilterPanel';
|
||||
import { mockUser } from '@/__tests__/helpers';
|
||||
|
||||
// Mock POIManager to avoid auth/poi dependencies
|
||||
vi.mock('@/components/POIManager', () => ({
|
||||
POIManager: () => <div data-testid="poi-manager">POIManager</div>,
|
||||
}));
|
||||
|
||||
interface HarnessProps {
|
||||
initialListingType?: ListingType;
|
||||
initialValues?: ParameterValues;
|
||||
}
|
||||
|
||||
/** Parent harness that controls listingType (matches App.tsx wiring). */
|
||||
function Harness({ initialListingType = ListingType.RENT, initialValues }: HarnessProps) {
|
||||
const [listingType, setListingType] = useState<ListingType>(initialListingType);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="external-buy-button"
|
||||
onClick={() => setListingType(ListingType.BUY)}
|
||||
>
|
||||
External BUY
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="external-rent-button"
|
||||
onClick={() => setListingType(ListingType.RENT)}
|
||||
>
|
||||
External RENT
|
||||
</button>
|
||||
<span data-testid="parent-listing-type">{listingType}</span>
|
||||
<FilterBar
|
||||
onSubmit={vi.fn()}
|
||||
isLoading={false}
|
||||
user={mockUser()}
|
||||
userPOIs={[]}
|
||||
onPOIsChange={vi.fn()}
|
||||
poiTravelFilters={{}}
|
||||
onPoiTravelFiltersChange={vi.fn()}
|
||||
listingType={listingType}
|
||||
onListingTypeChange={setListingType}
|
||||
poiPickerActive={false}
|
||||
onPoiPickerActiveChange={vi.fn()}
|
||||
pickedPoiLocation={null}
|
||||
onPickedPoiLocationChange={vi.fn()}
|
||||
currentMetric={Metric.qmprice}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('FilterBar — listing type transition (B2 regression)', () => {
|
||||
// Capture React's error log so we can assert no max-update-depth error fires.
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('mounts cleanly with parent listingType=RENT without infinite loop', () => {
|
||||
render(<Harness initialListingType={ListingType.RENT} />);
|
||||
expect(screen.getByTestId('parent-listing-type')).toHaveTextContent('RENT');
|
||||
// The trigger button should reflect RENT defaults (£2k – £3k).
|
||||
expect(screen.getByText(/£2k.*£3k/)).toBeInTheDocument();
|
||||
expect(errorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Maximum update depth'),
|
||||
);
|
||||
});
|
||||
|
||||
it('mounts cleanly with parent listingType=BUY (deep-link path)', () => {
|
||||
render(<Harness initialListingType={ListingType.BUY} />);
|
||||
expect(screen.getByTestId('parent-listing-type')).toHaveTextContent('BUY');
|
||||
expect(errorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Maximum update depth'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT throw "Maximum update depth exceeded" when parent switches RENT → BUY', () => {
|
||||
render(<Harness initialListingType={ListingType.RENT} />);
|
||||
|
||||
// Simulate clicking the header BUY tab (parent setListingType call).
|
||||
fireEvent.click(screen.getByTestId('external-buy-button'));
|
||||
|
||||
expect(screen.getByTestId('parent-listing-type')).toHaveTextContent('BUY');
|
||||
// The previous buggy code triggered React's "Maximum update depth exceeded" via
|
||||
// console.error within a few renders. Assert it never fires.
|
||||
const maxDepthCalls = errorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) => typeof arg === 'string' && arg.includes('Maximum update depth'),
|
||||
),
|
||||
);
|
||||
expect(maxDepthCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT crash on rapid RENT ↔ BUY toggles', () => {
|
||||
render(<Harness initialListingType={ListingType.RENT} />);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fireEvent.click(screen.getByTestId('external-buy-button'));
|
||||
fireEvent.click(screen.getByTestId('external-rent-button'));
|
||||
}
|
||||
expect(screen.getByTestId('parent-listing-type')).toHaveTextContent('RENT');
|
||||
const maxDepthCalls = errorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) => typeof arg === 'string' && arg.includes('Maximum update depth'),
|
||||
),
|
||||
);
|
||||
expect(maxDepthCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterBar — initialValues / deep-link defaults (B3 regression)', () => {
|
||||
it('renders URL-derived price range when initialValues is provided', () => {
|
||||
const initial: ParameterValues = {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
listing_type: ListingType.BUY,
|
||||
min_price: 500_000,
|
||||
max_price: 800_000,
|
||||
available_from: new Date(),
|
||||
};
|
||||
render(<Harness initialListingType={ListingType.BUY} initialValues={initial} />);
|
||||
// Price trigger should reflect the URL values, not the defaults.
|
||||
expect(screen.getByText(/£500k.*£800k/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to defaults when initialValues is omitted', () => {
|
||||
render(<Harness initialListingType={ListingType.RENT} />);
|
||||
// RENT default 2000-3000.
|
||||
expect(screen.getByText(/£2k.*£3k/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPrice (B22 regression)', () => {
|
||||
it('keeps one decimal for sub-10k values so 1500 and 2000 are distinguishable', () => {
|
||||
expect(formatPrice(1500)).toBe('£1.5k');
|
||||
expect(formatPrice(2500)).toBe('£2.5k');
|
||||
// Whole-thousand values stay integer (no trailing .0)
|
||||
expect(formatPrice(2000)).toBe('£2k');
|
||||
expect(formatPrice(3000)).toBe('£3k');
|
||||
});
|
||||
|
||||
it('rounds the chip-trigger label for >=10k values', () => {
|
||||
expect(formatPrice(50_000)).toBe('£50k');
|
||||
expect(formatPrice(500_000)).toBe('£500k');
|
||||
expect(formatPrice(800_000)).toBe('£800k');
|
||||
});
|
||||
|
||||
it('uses M-suffix for >=1M values', () => {
|
||||
expect(formatPrice(1_000_000)).toBe('£1.0M');
|
||||
expect(formatPrice(2_500_000)).toBe('£2.5M');
|
||||
});
|
||||
|
||||
it('renders raw integer for sub-1k values', () => {
|
||||
expect(formatPrice(500)).toBe('£500');
|
||||
expect(formatPrice(0)).toBe('£0');
|
||||
});
|
||||
});
|
||||
82
frontend/src/components/__tests__/FilterChips.test.tsx
Normal file
82
frontend/src/components/__tests__/FilterChips.test.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Tests for FilterChips (B17: Reset all affordance).
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { FilterChips } from '@/components/FilterChips';
|
||||
import { DEFAULT_FILTER_VALUES, type ParameterValues } from '@/components/FilterPanel';
|
||||
|
||||
const defaults: ParameterValues = {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
available_from: new Date(),
|
||||
};
|
||||
|
||||
describe('FilterChips', () => {
|
||||
it('renders no chips when values match defaults', () => {
|
||||
const { container } = render(
|
||||
<FilterChips values={defaults} defaults={defaults} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a chip for a price-range change', () => {
|
||||
const values: ParameterValues = { ...defaults, min_price: 1500, max_price: 2500 };
|
||||
render(<FilterChips values={values} defaults={defaults} onRemove={vi.fn()} />);
|
||||
// Chip should expose a remove button (proxy for chip existence)
|
||||
expect(screen.getByLabelText(/Remove .* filter/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render Reset all when no onResetAll handler is provided (B17)', () => {
|
||||
const values: ParameterValues = { ...defaults, district: 'Camden' };
|
||||
render(<FilterChips values={values} defaults={defaults} onRemove={vi.fn()} />);
|
||||
expect(screen.queryByText(/Reset all/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Reset all button when onResetAll is provided and chips exist (B17)', () => {
|
||||
const values: ParameterValues = { ...defaults, district: 'Camden' };
|
||||
render(
|
||||
<FilterChips
|
||||
values={values}
|
||||
defaults={defaults}
|
||||
onRemove={vi.fn()}
|
||||
onResetAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Reset all/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render Reset all when there are no active filters', () => {
|
||||
render(
|
||||
<FilterChips
|
||||
values={defaults}
|
||||
defaults={defaults}
|
||||
onRemove={vi.fn()}
|
||||
onResetAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Since no chips render, the whole component returns null
|
||||
expect(screen.queryByText(/Reset all/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onResetAll when the Reset all button is clicked (B17)', () => {
|
||||
const onResetAll = vi.fn();
|
||||
const values: ParameterValues = { ...defaults, district: 'Camden' };
|
||||
render(
|
||||
<FilterChips
|
||||
values={values}
|
||||
defaults={defaults}
|
||||
onRemove={vi.fn()}
|
||||
onResetAll={onResetAll}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Reset all/i));
|
||||
expect(onResetAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onRemove with the right key when a chip is removed', () => {
|
||||
const onRemove = vi.fn();
|
||||
const values: ParameterValues = { ...defaults, district: 'Camden' };
|
||||
render(<FilterChips values={values} defaults={defaults} onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByLabelText(/Remove .* filter/));
|
||||
expect(onRemove).toHaveBeenCalledWith('district');
|
||||
});
|
||||
});
|
||||
156
frontend/src/components/__tests__/Map.test.tsx
Normal file
156
frontend/src/components/__tests__/Map.test.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
|
||||
// Mock the constants module so we can flip the token between tests.
|
||||
// Map.tsx reads MAP_CONFIG.MAPBOX_TOKEN at component render time so a per-test
|
||||
// vi.doMock works as long as we re-import after the mock is registered.
|
||||
const COLOR_SCHEMES_MOCK = {
|
||||
getColorSchemeForMetric: () => [
|
||||
[0, 'rgba(0,0,0,0)'] as [number, string],
|
||||
[100, 'rgba(255,255,255,1)'] as [number, string],
|
||||
],
|
||||
getMetricInterpretation: () => ({ name: 'metric', low: 'low', high: 'high' }),
|
||||
};
|
||||
|
||||
// Mock the heatmap worker client — instantiated by Map.tsx on map 'load'.
|
||||
vi.mock('@/workers/HexgridHeatmapClient', () => ({
|
||||
HexgridHeatmapClient: vi.fn().mockImplementation(() => ({
|
||||
setIntensity: vi.fn(),
|
||||
setSpread: vi.fn(),
|
||||
setCellDensity: vi.fn(),
|
||||
setPropertyName: vi.fn(),
|
||||
setData: vi.fn(),
|
||||
setColorStops: vi.fn(),
|
||||
computeColorScale: vi.fn().mockResolvedValue({ hasValues: false, min: 0, max: 0 }),
|
||||
computeBounds: vi.fn().mockResolvedValue({ minLng: 0, minLat: 0, maxLng: 0, maxLat: 0 }),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
searchTree: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/constants/colorSchemes', () => COLOR_SCHEMES_MOCK);
|
||||
|
||||
describe('Map — B19/B29 token-missing banner', () => {
|
||||
const emptyData = { type: 'FeatureCollection' as const, features: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.doUnmock('@/constants');
|
||||
});
|
||||
|
||||
it('renders the "Map unavailable" banner when VITE_MAPBOX_TOKEN is empty', async () => {
|
||||
vi.doMock('@/constants', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/constants')>('@/constants');
|
||||
return {
|
||||
...actual,
|
||||
MAP_CONFIG: { ...actual.MAP_CONFIG, MAPBOX_TOKEN: '' },
|
||||
};
|
||||
});
|
||||
const { Map } = await import('@/components/Map');
|
||||
render(<Map listingData={emptyData} queryParameters={null} />);
|
||||
|
||||
const banner = screen.getByTestId('mapbox-token-missing-banner');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner.textContent).toMatch(/VITE_MAPBOX_TOKEN/);
|
||||
});
|
||||
|
||||
it('does NOT render the banner when the token is set', async () => {
|
||||
vi.doMock('@/constants', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/constants')>('@/constants');
|
||||
return {
|
||||
...actual,
|
||||
MAP_CONFIG: { ...actual.MAP_CONFIG, MAPBOX_TOKEN: 'pk.test' },
|
||||
};
|
||||
});
|
||||
const { Map } = await import('@/components/Map');
|
||||
render(<Map listingData={emptyData} queryParameters={null} />);
|
||||
|
||||
expect(screen.queryByTestId('mapbox-token-missing-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// B11 — When a HexgridHeatmapClient promise rejects post-destroy (rapid
|
||||
// Map↔List view toggle), Map.tsx must catch and swallow the rejection rather
|
||||
// than letting it bubble up as a pageerror.
|
||||
describe('Map — B11 post-destroy promise rejection is swallowed', () => {
|
||||
const emptyData = { type: 'FeatureCollection' as const, features: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.doUnmock('@/workers/HexgridHeatmapClient');
|
||||
vi.doUnmock('@/constants');
|
||||
});
|
||||
|
||||
it('does not throw when computeColorScale rejects with "destroyed"', async () => {
|
||||
// Replace the worker mock with one that rejects every async call
|
||||
vi.doMock('@/workers/HexgridHeatmapClient', () => ({
|
||||
HexgridHeatmapClient: vi.fn().mockImplementation(() => ({
|
||||
setIntensity: vi.fn(),
|
||||
setSpread: vi.fn(),
|
||||
setCellDensity: vi.fn(),
|
||||
setPropertyName: vi.fn(),
|
||||
setData: vi.fn(),
|
||||
setColorStops: vi.fn(),
|
||||
computeColorScale: vi.fn().mockRejectedValue(
|
||||
new Error('HexgridHeatmapClient destroyed'),
|
||||
),
|
||||
computeBounds: vi.fn().mockRejectedValue(
|
||||
new Error('HexgridHeatmapClient destroyed'),
|
||||
),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
searchTree: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.doMock('@/constants', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/constants')>('@/constants');
|
||||
return {
|
||||
...actual,
|
||||
MAP_CONFIG: { ...actual.MAP_CONFIG, MAPBOX_TOKEN: 'pk.test' },
|
||||
};
|
||||
});
|
||||
|
||||
// Spy on unhandledrejection at the global level
|
||||
const unhandled: PromiseRejectionEvent[] = [];
|
||||
const handler = (e: PromiseRejectionEvent) => unhandled.push(e);
|
||||
window.addEventListener('unhandledrejection', handler);
|
||||
|
||||
try {
|
||||
const { Map } = await import('@/components/Map');
|
||||
const dataWithOne = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: [{
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [0, 0] as [number, number] },
|
||||
properties: {
|
||||
id: 1, url: 'x', city: '', country: '', qm: 0, qmprice: 0,
|
||||
total_price: 0, rooms: 0, agency: '', available_from: '',
|
||||
last_seen: '', photo_thumbnail: '', price_history: [],
|
||||
},
|
||||
}],
|
||||
};
|
||||
render(<Map listingData={dataWithOne} queryParameters={null} />);
|
||||
|
||||
// Let microtasks settle so the awaited rejection has a chance to propagate
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// No unhandledrejection event should fire with "destroyed"
|
||||
const destroyedErr = unhandled.find((e) =>
|
||||
String((e.reason as Error)?.message ?? '').includes('destroyed'),
|
||||
);
|
||||
expect(destroyedErr).toBeUndefined();
|
||||
} finally {
|
||||
window.removeEventListener('unhandledrejection', handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
115
frontend/src/components/__tests__/PhotoCarousel.test.tsx
Normal file
115
frontend/src/components/__tests__/PhotoCarousel.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { PhotoCarousel } from '@/components/PhotoCarousel';
|
||||
import type { ListingDetailPhoto } from '@/types';
|
||||
|
||||
function photo(url: string, caption: string | null = null): ListingDetailPhoto {
|
||||
return { url, caption, type: null };
|
||||
}
|
||||
|
||||
describe('PhotoCarousel', () => {
|
||||
describe('B23 — single-photo and broken-image handling', () => {
|
||||
it('suppresses the N/M counter when there is exactly one photo', () => {
|
||||
render(<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />);
|
||||
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the N/M counter when there are multiple photos', () => {
|
||||
render(
|
||||
<PhotoCarousel
|
||||
photos={[
|
||||
photo('https://example.com/a.jpg'),
|
||||
photo('https://example.com/b.jpg'),
|
||||
photo('https://example.com/c.jpg'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces a broken image with a placeholder tile when onError fires', () => {
|
||||
render(
|
||||
<PhotoCarousel
|
||||
photos={[
|
||||
photo('https://example.com/good.jpg'),
|
||||
photo('https://example.com/bad.jpg'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const imgs = screen.getAllByRole('img');
|
||||
expect(imgs).toHaveLength(2);
|
||||
// Simulate the second image failing to load
|
||||
fireEvent.error(imgs[1]);
|
||||
// After error, the placeholder appears and the broken img is gone
|
||||
expect(screen.getByText(/Photo unavailable/i)).toBeInTheDocument();
|
||||
// The good image is still rendered
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still renders "No photos available" when photos is empty', () => {
|
||||
render(<PhotoCarousel photos={[]} />);
|
||||
expect(screen.getByText(/No photos available/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('B26 — single-photo carousel should not loop', () => {
|
||||
it('does not render prev/next buttons for a single-photo carousel', () => {
|
||||
render(<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />);
|
||||
expect(screen.queryByLabelText(/Previous photo/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Next photo/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render dots for a single-photo carousel', () => {
|
||||
const { container } = render(
|
||||
<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />,
|
||||
);
|
||||
// No "Go to photo X" buttons (dots) should be rendered when single
|
||||
expect(container.querySelectorAll('button[aria-label^="Go to photo"]'))
|
||||
.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders prev/next + dots for a multi-photo carousel', () => {
|
||||
render(
|
||||
<PhotoCarousel
|
||||
photos={[
|
||||
photo('https://example.com/a.jpg'),
|
||||
photo('https://example.com/b.jpg'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText(/Previous photo/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Next photo/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('B16 — keyboard navigation', () => {
|
||||
it('makes the multi-photo carousel root keyboard-focusable', () => {
|
||||
const { container } = render(
|
||||
<PhotoCarousel
|
||||
photos={[
|
||||
photo('https://example.com/a.jpg'),
|
||||
photo('https://example.com/b.jpg'),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// The Embla overflow container is the focusable root
|
||||
const region = container.querySelector('[role="region"][aria-label="Property photos"]');
|
||||
expect(region).not.toBeNull();
|
||||
// tabIndex should be set to 0 so the carousel can receive keydown
|
||||
expect((region as HTMLElement).tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('does not make a single-photo carousel keyboard-focusable', () => {
|
||||
const { container } = render(
|
||||
<PhotoCarousel photos={[photo('https://example.com/a.jpg')]} />,
|
||||
);
|
||||
const region = container.querySelector('[role="region"][aria-label="Property photos"]');
|
||||
// Region still exists (Embla wrapping) but should not be focusable, since
|
||||
// there's nothing to navigate to.
|
||||
expect(region).not.toBeNull();
|
||||
const tabIndex = (region as HTMLElement).tabIndex;
|
||||
// Default tabIndex on non-interactive elements is -1; we only flip to 0
|
||||
// for multi-photo carousels.
|
||||
expect(tabIndex).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -120,4 +120,71 @@ describe('PropertyCard', () => {
|
|||
expect(screen.queryByText(/NaN/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/d ago/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// B10 regression: null numerics must render as em-dash, NOT "£0" / "0 m²" / "£0/m²"
|
||||
it('renders em-dash placeholders for null total_price / qm / qmprice', () => {
|
||||
const partial = {
|
||||
...createMockProperty({ listing_type: 'BUY' }),
|
||||
total_price: null,
|
||||
qm: null,
|
||||
qmprice: null,
|
||||
} as unknown as PropertyProperties;
|
||||
const { container } = render(<PropertyCard property={partial} />);
|
||||
const text = container.textContent ?? '';
|
||||
// No deceptive "£0" / "0 m²" / "£0/m²"
|
||||
expect(text).not.toMatch(/£0\b/);
|
||||
expect(text).not.toMatch(/\b0\s*m²/);
|
||||
// Em-dash placeholders present
|
||||
expect(text).toContain('—');
|
||||
});
|
||||
|
||||
it('does not show /mo suffix when rent total_price is null', () => {
|
||||
const partial = {
|
||||
...createMockProperty({ listing_type: 'RENT' }),
|
||||
total_price: null,
|
||||
} as unknown as PropertyProperties;
|
||||
render(<PropertyCard property={partial} />);
|
||||
expect(screen.queryByText('/mo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// B25 regression: future last_seen must not produce negative "-7d ago"
|
||||
it('clamps future last_seen to 0d ago instead of negative days', () => {
|
||||
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const property = createMockProperty({ last_seen: future });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.queryByText(/-\d+d ago/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/0d ago/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// B15 regression: compact POI badges show all three modes with em-dash for missing
|
||||
it('renders all three travel modes per POI with em-dash for missing modes', () => {
|
||||
const property = createMockProperty({
|
||||
poi_distances: [
|
||||
{ poi_id: 1, poi_name: 'Office', travel_mode: 'WALK', duration_seconds: 540, distance_meters: 800 },
|
||||
],
|
||||
});
|
||||
const { container } = render(<PropertyCard property={property} />);
|
||||
// The POI block should contain "Office:" with three mode entries
|
||||
expect(container.textContent).toContain('Office:');
|
||||
// Walk value is rendered
|
||||
expect(container.textContent).toContain('9m');
|
||||
// Two em-dash placeholders for the missing BICYCLE and TRANSIT modes within
|
||||
// the badges. (Other em-dashes only appear if numerics are null — they aren't here.)
|
||||
const emDashes = (container.textContent ?? '').match(/—/g) ?? [];
|
||||
expect(emDashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('renders all three travel modes with values when all are present', () => {
|
||||
const property = createMockProperty({
|
||||
poi_distances: [
|
||||
{ poi_id: 1, poi_name: 'Office', travel_mode: 'WALK', duration_seconds: 540, distance_meters: 800 },
|
||||
{ poi_id: 1, poi_name: 'Office', travel_mode: 'BICYCLE', duration_seconds: 240, distance_meters: 1500 },
|
||||
{ poi_id: 1, poi_name: 'Office', travel_mode: 'TRANSIT', duration_seconds: 900, distance_meters: 3000 },
|
||||
],
|
||||
});
|
||||
const { container } = render(<PropertyCard property={property} />);
|
||||
expect(container.textContent).toContain('9m');
|
||||
expect(container.textContent).toContain('4m');
|
||||
expect(container.textContent).toContain('15m');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,6 +80,40 @@ describe('TaskIndicator', () => {
|
|||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// B27 — Per-task X cancel button must render in the DOM while the task is
|
||||
// in progress, and be discoverable both by aria-label and by data-testid.
|
||||
describe('B27 — cancel button visibility during PROGRESS', () => {
|
||||
it('renders the cancel X button when the active task is in progress', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
const { container } = renderIndicator(tasks, 't1');
|
||||
|
||||
expect(screen.getByTestId('task-cancel-button')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Cancel task')).toBeInTheDocument();
|
||||
// The lucide-x svg must be present inside that button
|
||||
const cancelBtn = container.querySelector('[data-testid="task-cancel-button"]');
|
||||
expect(cancelBtn).not.toBeNull();
|
||||
expect(cancelBtn?.querySelector('svg.lucide-x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT render the cancel X button once the task succeeds', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
|
||||
renderIndicator(tasks, 't1');
|
||||
expect(screen.queryByTestId('task-cancel-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render the cancel X button once the task fails', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'FAILURE' }) };
|
||||
renderIndicator(tasks, 't1');
|
||||
expect(screen.queryByTestId('task-cancel-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still renders the trash clear-all button when terminal', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
|
||||
renderIndicator(tasks, 't1');
|
||||
expect(screen.getByTestId('task-clear-all-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onTaskCompleted when active task transitions to SUCCESS', async () => {
|
||||
const onTaskCompleted = vi.fn();
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
|
|
|
|||
36
frontend/src/constants/__tests__/index.test.ts
Normal file
36
frontend/src/constants/__tests__/index.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { MAP_CONFIG } from '@/constants';
|
||||
|
||||
describe('MAP_CONFIG', () => {
|
||||
describe('B18 — default map center is London', () => {
|
||||
it('DEFAULT_CENTER points at London, not Czech Republic', () => {
|
||||
// London ≈ [-0.1276, 51.5074]. The old default ([13.38032, 49.994210])
|
||||
// landed in Czech Republic which is jarring for a UK-only app.
|
||||
const [lng, lat] = MAP_CONFIG.DEFAULT_CENTER;
|
||||
expect(lng).toBeGreaterThan(-1);
|
||||
expect(lng).toBeLessThan(1);
|
||||
expect(lat).toBeGreaterThan(51);
|
||||
expect(lat).toBeLessThan(52);
|
||||
});
|
||||
|
||||
it('DEFAULT_ZOOM gives a city-level view (not continent-level)', () => {
|
||||
// Anything around 10 is a city / inner-borough view in Mapbox terms.
|
||||
expect(MAP_CONFIG.DEFAULT_ZOOM).toBeGreaterThanOrEqual(9);
|
||||
expect(MAP_CONFIG.DEFAULT_ZOOM).toBeLessThanOrEqual(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe('B19 / B29 — Mapbox token sourced from env only', () => {
|
||||
it('reads from VITE_MAPBOX_TOKEN (the test setup sets it to test-token)', () => {
|
||||
// The test harness (src/__tests__/setup.ts) sets VITE_MAPBOX_TOKEN
|
||||
// to "test-token". The constant module reads import.meta.env at import time.
|
||||
expect(MAP_CONFIG.MAPBOX_TOKEN).toBe('test-token');
|
||||
});
|
||||
|
||||
it('does not contain a hard-coded Mapbox public key as a fallback', () => {
|
||||
// The previous code shipped a real public key (`pk.eyJ1...`). This regression
|
||||
// test ensures we never leak a token into the bundle again.
|
||||
expect(MAP_CONFIG.MAPBOX_TOKEN).not.toMatch(/^pk\.eyJ/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,11 +16,13 @@ export const API_ENDPOINTS = {
|
|||
} as const;
|
||||
|
||||
// Map configuration
|
||||
// MAPBOX_TOKEN is sourced exclusively from VITE_MAPBOX_TOKEN (B19/B29). When unset the
|
||||
// map renders a banner instead of attempting to load tiles with an embedded fallback.
|
||||
// DEFAULT_CENTER points at London since this is a UK-only listings app (B18).
|
||||
export const MAP_CONFIG = {
|
||||
// Dev fallback token — production builds must set VITE_MAPBOX_TOKEN
|
||||
MAPBOX_TOKEN: import.meta.env.VITE_MAPBOX_TOKEN || 'pk.eyJ1IjoiZGktdG8iLCJhIjoiY2o0bnBoYXcxMW1mNzJ3bDhmc2xiNWttaiJ9.ZccatVk_4shzoAsEUXXecA',
|
||||
DEFAULT_CENTER: [13.38032, 49.994210] as [number, number],
|
||||
DEFAULT_ZOOM: 5,
|
||||
MAPBOX_TOKEN: import.meta.env.VITE_MAPBOX_TOKEN ?? '',
|
||||
DEFAULT_CENTER: [-0.1276, 51.5074] as [number, number],
|
||||
DEFAULT_ZOOM: 10,
|
||||
STYLE: 'mapbox://styles/mapbox/light-v9',
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
133
frontend/src/hooks/__tests__/useFilterParams.test.tsx
Normal file
133
frontend/src/hooks/__tests__/useFilterParams.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Tests for useFilterParams (B3 regression).
|
||||
*
|
||||
* B3: previously useFilterParams exposed setFilterValues but App.tsx never imported it.
|
||||
* Filter changes never reached the URL → reload reverted, share-URL dropped state,
|
||||
* deep links were ignored. These tests cover the URL round-trip: parse, write, and
|
||||
* restore via the React Router search params.
|
||||
*/
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||
import { ListingType, Metric, FurnishType } from '@/components/FilterPanel';
|
||||
|
||||
function wrapperFactory(initialEntries: string[]) {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFilterParams — URL parsing (deep-link, B3)', () => {
|
||||
it('parses ?type=BUY into ListingType.BUY', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?type=BUY']),
|
||||
});
|
||||
expect(result.current.filterValues.listing_type).toBe(ListingType.BUY);
|
||||
});
|
||||
|
||||
it('parses ?minPrice=500000&maxPrice=800000 correctly', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?type=BUY&minPrice=500000&maxPrice=800000']),
|
||||
});
|
||||
expect(result.current.filterValues.min_price).toBe(500000);
|
||||
expect(result.current.filterValues.max_price).toBe(800000);
|
||||
});
|
||||
|
||||
it('parses ?minBeds=2&maxBeds=4 correctly', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?minBeds=2&maxBeds=4']),
|
||||
});
|
||||
expect(result.current.filterValues.min_bedrooms).toBe(2);
|
||||
expect(result.current.filterValues.max_bedrooms).toBe(4);
|
||||
});
|
||||
|
||||
it('parses ?minSqm=80&lastSeen=14 correctly', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?minSqm=80&lastSeen=14']),
|
||||
});
|
||||
expect(result.current.filterValues.min_sqm).toBe(80);
|
||||
expect(result.current.filterValues.last_seen_days).toBe(14);
|
||||
});
|
||||
|
||||
it('parses ?district=Camden&furnish=furnished,partFurnished correctly', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?district=Camden&furnish=furnished,partFurnished']),
|
||||
});
|
||||
expect(result.current.filterValues.district).toBe('Camden');
|
||||
expect(result.current.filterValues.furnish_types).toEqual([
|
||||
FurnishType.FURNISHED,
|
||||
FurnishType.PART_FURNISHED,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to defaults for malformed values', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/?minPrice=not-a-number&type=BOGUS']),
|
||||
});
|
||||
// Type defaults to RENT, price falls back to default.
|
||||
expect(result.current.filterValues.listing_type).toBe(ListingType.RENT);
|
||||
expect(result.current.filterValues.min_price).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFilterParams — URL round-trip (B3 regression)', () => {
|
||||
it('setFilterValues writes the filter state into the URL', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/']),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setFilterValues({
|
||||
...result.current.filterValues,
|
||||
listing_type: ListingType.BUY,
|
||||
min_price: 500000,
|
||||
max_price: 800000,
|
||||
min_bedrooms: 2,
|
||||
max_bedrooms: 4,
|
||||
min_sqm: 80,
|
||||
last_seen_days: 14,
|
||||
metric: Metric.qmprice,
|
||||
district: '',
|
||||
});
|
||||
});
|
||||
|
||||
// After the write, re-render reads back the new URL state.
|
||||
expect(result.current.filterValues.listing_type).toBe(ListingType.BUY);
|
||||
expect(result.current.filterValues.min_price).toBe(500000);
|
||||
expect(result.current.filterValues.max_price).toBe(800000);
|
||||
expect(result.current.filterValues.min_bedrooms).toBe(2);
|
||||
expect(result.current.filterValues.max_bedrooms).toBe(4);
|
||||
expect(result.current.filterValues.min_sqm).toBe(80);
|
||||
expect(result.current.filterValues.last_seen_days).toBe(14);
|
||||
});
|
||||
|
||||
it('round-trips an arbitrary filter set unchanged', () => {
|
||||
const { result } = renderHook(() => useFilterParams(), {
|
||||
wrapper: wrapperFactory(['/']),
|
||||
});
|
||||
|
||||
const written = {
|
||||
...result.current.filterValues,
|
||||
listing_type: ListingType.BUY,
|
||||
min_price: 250000,
|
||||
max_price: 450000,
|
||||
min_bedrooms: 3,
|
||||
max_bedrooms: 5,
|
||||
district: 'Hackney',
|
||||
last_seen_days: 7,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setFilterValues(written);
|
||||
});
|
||||
|
||||
expect(result.current.filterValues.listing_type).toBe(written.listing_type);
|
||||
expect(result.current.filterValues.min_price).toBe(written.min_price);
|
||||
expect(result.current.filterValues.max_price).toBe(written.max_price);
|
||||
expect(result.current.filterValues.min_bedrooms).toBe(written.min_bedrooms);
|
||||
expect(result.current.filterValues.max_bedrooms).toBe(written.max_bedrooms);
|
||||
expect(result.current.filterValues.district).toBe(written.district);
|
||||
expect(result.current.filterValues.last_seen_days).toBe(written.last_seen_days);
|
||||
});
|
||||
});
|
||||
|
|
@ -224,4 +224,44 @@ describe('useTaskProgress', () => {
|
|||
expect.objectContaining({ accessToken: 'test-access-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
// B28 — Polling must NOT keep hitting /api/task_status for IDs that are
|
||||
// already terminal locally, even if the server keeps returning them from
|
||||
// /api/tasks_for_user during the retention window.
|
||||
it('skips polling task_status for IDs whose local status is terminal', async () => {
|
||||
const { fetchTasksForUser, fetchTaskStatus } = await import('@/services');
|
||||
const fetchTasksForUserMock = fetchTasksForUser as unknown as ReturnType<typeof vi.fn>;
|
||||
const fetchTaskStatusMock = fetchTaskStatus as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Server keeps returning a terminal task ID
|
||||
fetchTasksForUserMock.mockResolvedValue(['t-done']);
|
||||
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
// Seed the local cache via WS init so the hook knows t-done is SUCCESS
|
||||
act(() => {
|
||||
ws.simulateOpen();
|
||||
ws.simulateMessage({
|
||||
type: 'init',
|
||||
tasks: [createMockTaskState({ task_id: 't-done', status: 'SUCCESS' })],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.tasks['t-done'].status).toBe('SUCCESS');
|
||||
|
||||
// Clear any fetch calls accumulated so far so we measure only what comes next
|
||||
fetchTaskStatusMock.mockClear();
|
||||
|
||||
// Trigger a polling round and let microtasks settle
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
|
||||
// fetchTaskStatus must NOT have been called for the terminal-local task
|
||||
const calledWithDone = fetchTaskStatusMock.mock.calls.some(
|
||||
([, taskId]) => taskId === 't-done',
|
||||
);
|
||||
expect(calledWithDone).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,7 +137,13 @@ export function useTaskProgress(user: AuthUser | null): UseTaskProgressReturn {
|
|||
.filter(([, t]) => !isTerminalStatus(t.status))
|
||||
.map(([id]) => id);
|
||||
|
||||
const allIds = [...new Set([...taskIds, ...localIds])];
|
||||
// Stop polling task IDs that are already terminal locally — the server may
|
||||
// keep returning them from /api/tasks_for_user for some retention window
|
||||
// but there's no point re-fetching their status (B28).
|
||||
const allIds = [...new Set([...taskIds, ...localIds])].filter((id) => {
|
||||
const local = tasksRef.current[id];
|
||||
return !local || !isTerminalStatus(local.status);
|
||||
});
|
||||
if (allIds.length === 0) return;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mockUser, createMockFeature } from '@/__tests__/helpers';
|
||||
import { streamListingGeoJSON } from '@/services/streamingService';
|
||||
import { streamListingGeoJSON, StreamParseError } from '@/services/streamingService';
|
||||
import { ApiError } from '@/types';
|
||||
import type { ParameterValues } from '@/components/FilterPanel';
|
||||
|
||||
|
|
@ -211,4 +211,46 @@ describe('streamingService', () => {
|
|||
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ }
|
||||
}).rejects.toThrow('No response body');
|
||||
});
|
||||
|
||||
// B7 regression: HTML response (e.g. proxy SPA fallback when backend is down)
|
||||
// should bail on the first parse error instead of looping 18× per stream.
|
||||
it('throws StreamParseError on the first unparseable line when nothing has parsed yet', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const htmlLines = [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head><title>App</title></head>',
|
||||
' <body><div id="root"></div></body>',
|
||||
'</html>',
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(htmlLines));
|
||||
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ }
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(StreamParseError);
|
||||
// Critical: no console.error spam — bail immediately on first failure.
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('StreamParseError captures a snippet of the offending input', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse(['<!doctype html><html><head>...</head>']),
|
||||
);
|
||||
|
||||
let caught: StreamParseError | null = null;
|
||||
try {
|
||||
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ }
|
||||
} catch (e) {
|
||||
caught = e as StreamParseError;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(StreamParseError);
|
||||
expect(caught?.snippet).toContain('<!doctype html>');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Re-export all services
|
||||
export { apiRequest } from './apiClient';
|
||||
export { fetchListingGeoJSON, refreshListings } from './listingService';
|
||||
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
|
||||
export { streamListingGeoJSON, StreamParseError, type StreamingProgress } from './streamingService';
|
||||
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
|
||||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances, fetchBulkPOIDistances } from './poiService';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,22 @@ import { API_ENDPOINTS } from '@/constants';
|
|||
import { fireUnauthorized } from './apiClient';
|
||||
import { record as recordPerf } from './perfCollector';
|
||||
|
||||
/**
|
||||
* Thrown when the stream endpoint returns a response that isn't NDJSON (e.g. HTML
|
||||
* because the backend is down and a proxy served the SPA fallback, or an auth
|
||||
* redirect). Surfacing this as a typed error lets `loadListings` show a single
|
||||
* user-visible error dialog instead of dribbling out one console.error per line.
|
||||
*/
|
||||
export class StreamParseError extends Error {
|
||||
constructor(public readonly snippet: string) {
|
||||
super(
|
||||
`Server returned non-streaming response (expected NDJSON). ` +
|
||||
`First bytes: ${snippet.slice(0, 80)}${snippet.length > 80 ? '…' : ''}`,
|
||||
);
|
||||
this.name = 'StreamParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters object
|
||||
*/
|
||||
|
|
@ -102,6 +118,11 @@ export async function* streamListingGeoJSON(
|
|||
let totalCount = 0;
|
||||
let streamStart = performance.now();
|
||||
let firstBatchRecorded = false;
|
||||
// Track whether we've successfully parsed at least one NDJSON message. If the
|
||||
// very first non-empty line fails to parse, the server almost certainly returned
|
||||
// HTML (proxy SPA fallback, login redirect, etc) — bail with a typed error
|
||||
// instead of spamming console for every line.
|
||||
let parsedAny = false;
|
||||
|
||||
while (true) {
|
||||
if (options?.signal?.aborted) {
|
||||
|
|
@ -120,6 +141,7 @@ export async function* streamListingGeoJSON(
|
|||
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(line);
|
||||
parsedAny = true;
|
||||
|
||||
if (message.type === 'metadata') {
|
||||
onProgress?.({ count: 0, total: message.total_expected });
|
||||
|
|
@ -135,6 +157,15 @@ export async function* streamListingGeoJSON(
|
|||
onProgress?.({ count: message.total ?? totalCount, total: message.total });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!parsedAny) {
|
||||
// First line was unparseable — assume the entire response is non-NDJSON
|
||||
// (e.g. HTML). Cancel the reader so we stop consuming the body and
|
||||
// surface a typed error to the caller.
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new StreamParseError(line);
|
||||
}
|
||||
// A mid-stream parse hiccup after we've already received valid messages
|
||||
// — log and continue (matches the prior tolerant behaviour).
|
||||
console.error('Failed to parse streaming message:', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -144,10 +175,14 @@ export async function* streamListingGeoJSON(
|
|||
if (buffer.trim()) {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(buffer);
|
||||
parsedAny = true;
|
||||
if (message.type === 'batch' && message.features) {
|
||||
yield message.features;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!parsedAny) {
|
||||
throw new StreamParseError(buffer);
|
||||
}
|
||||
console.error('Failed to parse final streaming message:', e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
150
frontend/src/utils/__tests__/format.test.ts
Normal file
150
frontend/src/utils/__tests__/format.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
EM_DASH,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatInteger,
|
||||
formatPrice,
|
||||
formatPricePerSqm,
|
||||
formatPricePerSqmShort,
|
||||
isFiniteNumber,
|
||||
} from '@/utils/format';
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
it('formats values >= 1000 in compact k notation', () => {
|
||||
expect(formatCurrency(1500)).toBe('£1.5k');
|
||||
expect(formatCurrency(2500)).toBe('£2.5k');
|
||||
expect(formatCurrency(1000000)).toBe('£1000.0k');
|
||||
});
|
||||
|
||||
it('formats sub-thousand values as rounded integers', () => {
|
||||
expect(formatCurrency(950)).toBe('£950');
|
||||
expect(formatCurrency(123.4)).toBe('£123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('renders minutes for sub-hour durations', () => {
|
||||
expect(formatDuration(0)).toBe('0m');
|
||||
expect(formatDuration(120)).toBe('2m');
|
||||
expect(formatDuration(3540)).toBe('59m');
|
||||
});
|
||||
|
||||
it('renders hours-and-minutes for multi-hour durations', () => {
|
||||
expect(formatDuration(3600)).toBe('1h');
|
||||
expect(formatDuration(5400)).toBe('1h30m');
|
||||
expect(formatDuration(86400)).toBe('24h');
|
||||
});
|
||||
|
||||
// B9 regression cases
|
||||
it('returns em-dash for negative seconds (was "-2m")', () => {
|
||||
expect(formatDuration(-120)).toBe(EM_DASH);
|
||||
expect(formatDuration(-1)).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
it('returns em-dash for null and undefined (was "0m")', () => {
|
||||
expect(formatDuration(null)).toBe(EM_DASH);
|
||||
expect(formatDuration(undefined)).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
it('returns em-dash for NaN (was "NaNh")', () => {
|
||||
expect(formatDuration(NaN)).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
it('returns em-dash for non-finite values (Infinity)', () => {
|
||||
expect(formatDuration(Infinity)).toBe(EM_DASH);
|
||||
expect(formatDuration(-Infinity)).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
it('caps absurdly large values at >24h', () => {
|
||||
expect(formatDuration(86401)).toBe('>24h');
|
||||
expect(formatDuration(90000)).toBe('>24h');
|
||||
expect(formatDuration(31536000)).toBe('>24h'); // 1 year
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats valid ISO dates', () => {
|
||||
expect(formatDate('2025-01-03T00:00:00Z')).toContain('2025');
|
||||
});
|
||||
|
||||
it('returns the input string for invalid dates', () => {
|
||||
expect(formatDate('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPricePerSqm', () => {
|
||||
it('returns the formatted string when both values are valid', () => {
|
||||
expect(formatPricePerSqm(500000, 100)).toBe('£5,000/m²');
|
||||
});
|
||||
|
||||
it('returns null when sqm is missing', () => {
|
||||
expect(formatPricePerSqm(500000, null)).toBe(null);
|
||||
expect(formatPricePerSqm(500000, undefined)).toBe(null);
|
||||
expect(formatPricePerSqm(500000, 0)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFiniteNumber', () => {
|
||||
it('returns true for finite numbers', () => {
|
||||
expect(isFiniteNumber(0)).toBe(true);
|
||||
expect(isFiniteNumber(-1)).toBe(true);
|
||||
expect(isFiniteNumber(1.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-finite, null, undefined, and other types', () => {
|
||||
expect(isFiniteNumber(null)).toBe(false);
|
||||
expect(isFiniteNumber(undefined)).toBe(false);
|
||||
expect(isFiniteNumber(NaN)).toBe(false);
|
||||
expect(isFiniteNumber(Infinity)).toBe(false);
|
||||
expect(isFiniteNumber('5')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats finite numbers with thousands separators', () => {
|
||||
expect(formatPrice(0)).toBe('£0');
|
||||
expect(formatPrice(2500)).toBe('£2,500');
|
||||
expect(formatPrice(500000)).toBe('£500,000');
|
||||
expect(formatPrice(1234.7)).toBe('£1,235');
|
||||
});
|
||||
|
||||
// B10 regression: null/undefined must NOT render as "£0"
|
||||
it('returns em-dash for null/undefined/non-finite', () => {
|
||||
expect(formatPrice(null)).toBe(EM_DASH);
|
||||
expect(formatPrice(undefined)).toBe(EM_DASH);
|
||||
expect(formatPrice(NaN)).toBe(EM_DASH);
|
||||
expect(formatPrice(Infinity)).toBe(EM_DASH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPricePerSqmShort', () => {
|
||||
it('formats valid positive values', () => {
|
||||
expect(formatPricePerSqmShort(38)).toBe('£38/m²');
|
||||
expect(formatPricePerSqmShort(4500)).toBe('£4500/m²');
|
||||
});
|
||||
|
||||
// B10: zero & negative are missing-data sentinels, not real values
|
||||
it('returns em-dash for null/undefined/non-finite/zero/negative', () => {
|
||||
expect(formatPricePerSqmShort(null)).toBe(EM_DASH);
|
||||
expect(formatPricePerSqmShort(undefined)).toBe(EM_DASH);
|
||||
expect(formatPricePerSqmShort(NaN)).toBe(EM_DASH);
|
||||
expect(formatPricePerSqmShort(0)).toBe(EM_DASH);
|
||||
expect(formatPricePerSqmShort(-5)).toBe(EM_DASH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInteger', () => {
|
||||
it('rounds and stringifies finite numbers', () => {
|
||||
expect(formatInteger(0)).toBe('0');
|
||||
expect(formatInteger(3)).toBe('3');
|
||||
expect(formatInteger(65.7)).toBe('66');
|
||||
});
|
||||
|
||||
it('returns em-dash for null/undefined/non-finite', () => {
|
||||
expect(formatInteger(null)).toBe(EM_DASH);
|
||||
expect(formatInteger(undefined)).toBe(EM_DASH);
|
||||
expect(formatInteger(NaN)).toBe(EM_DASH);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,14 +5,62 @@
|
|||
* PropertyCard, ListingDetail, MobileBottomSheet, and StatsBar.
|
||||
*/
|
||||
|
||||
/** Em-dash placeholder used at render boundaries for missing/invalid values. */
|
||||
export const EM_DASH = '—';
|
||||
|
||||
/** Returns true when v is a finite number — i.e. safe to render directly. */
|
||||
export function isFiniteNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer GBP price with thousands separators, e.g. "£2,500".
|
||||
* Returns the em-dash sentinel for null / undefined / non-finite inputs.
|
||||
* Use at the JSX leaf to distinguish "missing data" from "£0".
|
||||
*/
|
||||
export function formatPrice(value: number | null | undefined): string {
|
||||
if (!isFiniteNumber(value)) return EM_DASH;
|
||||
return `£${Math.round(value).toLocaleString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price-per-square-metre integer, e.g. "£42/m²".
|
||||
* Returns the em-dash sentinel for null / undefined / non-finite / zero / negative inputs.
|
||||
* (Zero / negative qmprice values come from missing-data rows, not real listings.)
|
||||
*/
|
||||
export function formatPricePerSqmShort(value: number | null | undefined): string {
|
||||
if (!isFiniteNumber(value) || value <= 0) return EM_DASH;
|
||||
return `£${Math.round(value)}/m²`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer count (bedrooms, square metres, etc).
|
||||
* Returns the em-dash sentinel for null / undefined / non-finite inputs.
|
||||
*/
|
||||
export function formatInteger(value: number | null | undefined): string {
|
||||
if (!isFiniteNumber(value)) return EM_DASH;
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
||||
/** Format a number as a compact GBP string, e.g. "£1.2k" or "£950". */
|
||||
export function formatCurrency(value: number): string {
|
||||
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
|
||||
return `£${Math.round(value)}`;
|
||||
}
|
||||
|
||||
/** Format a duration in seconds as a human-readable string, e.g. "12m" or "1h30m". */
|
||||
export function formatDuration(seconds: number): string {
|
||||
/**
|
||||
* Format a duration in seconds as a human-readable string, e.g. "12m" or "1h30m".
|
||||
*
|
||||
* Returns the em-dash sentinel for non-finite or negative inputs (null, NaN, -120…).
|
||||
* Caps absurdly large values (> 24h) as ">24h" to avoid rendering nonsense like
|
||||
* "8760h" when a backend bug produces year-scale values.
|
||||
*/
|
||||
export function formatDuration(seconds: number | null | undefined): string {
|
||||
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) {
|
||||
return EM_DASH;
|
||||
}
|
||||
// Cap at 24 hours — beyond this it's almost certainly bad data.
|
||||
if (seconds > 24 * 3600) return '>24h';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ function isStale(type: string, requestId: number): boolean {
|
|||
return (latestRequestId[type] ?? 0) > requestId;
|
||||
}
|
||||
|
||||
// Average non-NaN values (same reduce function as original HexgridHeatmap)
|
||||
// Average finite numeric values, excluding null/undefined/NaN/infinity.
|
||||
// We can't use `!isNaN(data[i])` because isNaN(null) === false (Number(null) === 0),
|
||||
// which would silently pull missing values in as zeros and bias the average.
|
||||
function reduceAverage(data: number[]): number {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (!isNaN(data[i])) {
|
||||
if (typeof data[i] === 'number' && Number.isFinite(data[i])) {
|
||||
sum += data[i];
|
||||
count++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,27 @@ spec:
|
|||
kubernetes.io/cluster-service: "true"
|
||||
spec:
|
||||
automountServiceAccountToken: true
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
initContainers:
|
||||
# One-shot chown so existing NFS-backed listing dirs (mode 775, owned
|
||||
# by various historical UIDs) become writable by the new uid=1000
|
||||
# appuser. Idempotent: chown of already-owned files is a no-op.
|
||||
- name: fix-data-permissions
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
chown -R 1000:1000 /app/data || true
|
||||
chmod -R u+rwX,g+rwX /app/data || true
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- mountPath: /app/data
|
||||
name: data
|
||||
containers:
|
||||
- env:
|
||||
- name: ENV
|
||||
|
|
|
|||
|
|
@ -25,6 +25,27 @@ spec:
|
|||
app: realestate-crawler-celery-beat
|
||||
spec:
|
||||
automountServiceAccountToken: true
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
initContainers:
|
||||
# One-shot chown so existing NFS-backed listing dirs (mode 775, owned
|
||||
# by various historical UIDs) become writable by the new uid=1000
|
||||
# appuser. Idempotent: chown of already-owned files is a no-op.
|
||||
- name: fix-data-permissions
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
chown -R 1000:1000 /app/data || true
|
||||
chmod -R u+rwX,g+rwX /app/data || true
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- mountPath: /app/data
|
||||
name: data
|
||||
containers:
|
||||
- command:
|
||||
- python
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import os
|
||||
from string import Template
|
||||
from typing import Any, TypeVar
|
||||
from api.auth import User
|
||||
|
|
@ -9,16 +10,35 @@ from celery_app import app
|
|||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Default Redis logical DB for per-user state (task lists, WebAuthn
|
||||
# challenges). Previously this lived on db0 alongside the Celery broker
|
||||
# AND another app's kombu bindings (paperless-ngx). Moving to db3 isolates
|
||||
# user state from broker traffic and prevents key collisions.
|
||||
_DEFAULT_USER_DB = 3
|
||||
# Namespace for every key written by this class so any other process
|
||||
# sharing the Redis instance can't collide.
|
||||
_KEY_PREFIX = "wrongmove:user:"
|
||||
|
||||
|
||||
class RedisRepository:
|
||||
redis_client: redis.Redis # type: ignore[type-arg]
|
||||
tasks_key_template: Template = Template("user:{user_id}/tasks")
|
||||
# tasks_key_template is the *suffix* portion; set_key / get_key uniformly
|
||||
# prepend ``_KEY_PREFIX`` so every key this class writes is namespaced.
|
||||
tasks_key_template: Template = Template("${user_id}/tasks")
|
||||
|
||||
def __init__(self) -> None:
|
||||
redis_hostname: str = app.broker_connection().info()["hostname"]
|
||||
redis_port: int = app.broker_connection().info()["port"]
|
||||
db = int(os.getenv("REDIS_USER_DB", str(_DEFAULT_USER_DB)))
|
||||
# socket_keepalive + health_check_interval keep the connection
|
||||
# alive across the Redis HAProxy 30s idle timeout (see celery_app.py).
|
||||
self.redis_client = redis.Redis(
|
||||
host=redis_hostname, port=redis_port, db=0, decode_responses=True
|
||||
host=redis_hostname,
|
||||
port=redis_port,
|
||||
db=db,
|
||||
decode_responses=True,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=25,
|
||||
) # decode_responses=True returns str, not bytes
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -26,15 +46,23 @@ class RedisRepository:
|
|||
def instance() -> "RedisRepository":
|
||||
return RedisRepository()
|
||||
|
||||
@staticmethod
|
||||
def _prefixed(key: str) -> str:
|
||||
"""Prepend the wrongmove user-namespace prefix if not already present."""
|
||||
if key.startswith(_KEY_PREFIX):
|
||||
return key
|
||||
return f"{_KEY_PREFIX}{key}"
|
||||
|
||||
def set_key(self, key: str, value: Any, ttl: timedelta | None = None) -> None:
|
||||
full_key = self._prefixed(key)
|
||||
serialized_value = self.__serialize_value(value)
|
||||
self.redis_client.set(key, serialized_value)
|
||||
self.redis_client.set(full_key, serialized_value)
|
||||
|
||||
ttl = ttl or timedelta(hours=3)
|
||||
self.redis_client.expire(key, ttl)
|
||||
self.redis_client.expire(full_key, ttl)
|
||||
|
||||
def get_key(self, key: str) -> Any | None:
|
||||
serialized_value = self.redis_client.get(key)
|
||||
serialized_value = self.redis_client.get(self._prefixed(key))
|
||||
if serialized_value is None:
|
||||
return None
|
||||
return self.__deserialize_value(serialized_value)
|
||||
|
|
@ -71,7 +99,9 @@ class RedisRepository:
|
|||
"""Clear all tasks for a user. Returns the number of tasks cleared."""
|
||||
current_tasks: list[str] = self.get_tasks_for_user(user)
|
||||
count = len(current_tasks)
|
||||
self.redis_client.delete(self.tasks_key_template.substitute(user_id=user.email))
|
||||
self.redis_client.delete(
|
||||
self._prefixed(self.tasks_key_template.substitute(user_id=user.email))
|
||||
)
|
||||
return count
|
||||
|
||||
def __serialize_value(self, value: Any) -> str:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,14 @@ def _get_redis_client() -> redis.Redis:
|
|||
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
parsed = urlparse(broker_url)
|
||||
cache_url = urlunparse(parsed._replace(path=f"/{CACHE_DB}"))
|
||||
return redis.from_url(cache_url, decode_responses=True)
|
||||
# socket_keepalive + health_check_interval keep the connection alive
|
||||
# across the Redis HAProxy 30s idle timeout (see celery_app.py).
|
||||
return redis.from_url(
|
||||
cache_url,
|
||||
decode_responses=True,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=25,
|
||||
)
|
||||
|
||||
|
||||
def make_cache_key(query_params: QueryParameters) -> str:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,15 @@ def _get_redis_client() -> redis.Redis: # type: ignore[type-arg]
|
|||
global _redis_client
|
||||
if _redis_client is None:
|
||||
broker_url = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
|
||||
_redis_client = redis.Redis.from_url(broker_url, decode_responses=True)
|
||||
# socket_keepalive + health_check_interval keep the connection
|
||||
# alive across the Redis HAProxy 30s idle timeout that fronts the
|
||||
# in-cluster Redis Sentinel.
|
||||
_redis_client = redis.Redis.from_url(
|
||||
broker_url,
|
||||
decode_responses=True,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=25,
|
||||
)
|
||||
return _redis_client
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -339,7 +339,13 @@ async def _process_worker(
|
|||
state: _PipelineState,
|
||||
reporter: ProgressReporter,
|
||||
) -> None:
|
||||
"""Consumer worker: pull listing IDs from the queue and process them."""
|
||||
"""Consumer worker: pull listing IDs from the queue and process them.
|
||||
|
||||
Per-listing exceptions (PermissionError, OSError, asyncio.TimeoutError,
|
||||
etc.) are caught, counted, logged, and skipped so a single bad listing
|
||||
cannot abort the entire scrape pipeline. CancelledError is re-raised so
|
||||
cooperative cancellation still works.
|
||||
"""
|
||||
while True:
|
||||
listing_id = await queue.get()
|
||||
if listing_id is None:
|
||||
|
|
@ -353,19 +359,42 @@ async def _process_worker(
|
|||
elif step_name == "ocr":
|
||||
state.ocr_completed += 1
|
||||
|
||||
listing = await processor.process_listing(
|
||||
listing_id, on_step_complete=step_callback
|
||||
)
|
||||
if listing is not None:
|
||||
state.processed_count += 1
|
||||
state.processed_listings.append(listing)
|
||||
else:
|
||||
try:
|
||||
listing = await processor.process_listing(
|
||||
listing_id, on_step_complete=step_callback
|
||||
)
|
||||
if listing is not None:
|
||||
state.processed_count += 1
|
||||
state.processed_listings.append(listing)
|
||||
else:
|
||||
state.failed_count += 1
|
||||
except asyncio.CancelledError:
|
||||
# Cooperative cancellation — let it propagate so the gather()
|
||||
# in dump_listings_full can unwind cleanly.
|
||||
raise
|
||||
except Exception:
|
||||
state.failed_count += 1
|
||||
celery_logger.exception(
|
||||
"Unhandled exception processing listing %s; skipping", listing_id
|
||||
)
|
||||
reporter.notify()
|
||||
|
||||
|
||||
|
||||
@app.task(bind=True, pydantic=True)
|
||||
@app.task(
|
||||
bind=True,
|
||||
pydantic=True,
|
||||
# Hard kill if the task hasn't completed in 1h (matches the SLA — a
|
||||
# full scrape takes ~10-15 min in practice). soft_time_limit raises
|
||||
# SoftTimeLimitExceeded so cleanup / FAILURE-publish runs first.
|
||||
time_limit=3600,
|
||||
soft_time_limit=3500,
|
||||
# acks_late so a worker crash mid-task re-queues the task instead of
|
||||
# acknowledging-and-losing it. Combined with the visibility_timeout
|
||||
# defaults, this is safe because the lock at the top of the body
|
||||
# prevents two workers running concurrently.
|
||||
acks_late=True,
|
||||
)
|
||||
def dump_listings_task(self: Task, parameters_json: str) -> dict[str, Any]:
|
||||
with redis_lock(SCRAPE_LOCK_NAME) as acquired:
|
||||
if not acquired:
|
||||
|
|
@ -383,7 +412,24 @@ def dump_listings_task(self: Task, parameters_json: str) -> dict[str, Any]:
|
|||
|
||||
self.update_state(state="Starting...", meta={"phase": PHASE_SPLITTING, "progress": 0})
|
||||
publish_task_progress(self.request.id, "Starting...", {"phase": PHASE_SPLITTING, "progress": 0})
|
||||
asyncio.run(dump_listings_full(task=self, parameters=parsed_parameters))
|
||||
try:
|
||||
asyncio.run(dump_listings_full(task=self, parameters=parsed_parameters))
|
||||
except Exception as exc:
|
||||
# Publish a terminal FAILURE event so WebSocket subscribers update
|
||||
# immediately, then re-raise so Celery records the failure in
|
||||
# the result backend.
|
||||
celery_logger.exception("dump_listings_task failed: %s", exc)
|
||||
publish_task_progress(
|
||||
self.request.id,
|
||||
"FAILURE",
|
||||
{
|
||||
"phase": PHASE_COMPLETED,
|
||||
"progress": 0,
|
||||
"error": str(exc),
|
||||
"exc_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
result = {"phase": PHASE_COMPLETED, "progress": 1}
|
||||
publish_task_progress(self.request.id, "SUCCESS", result)
|
||||
return result
|
||||
|
|
|
|||
37
tests/unit/test_dockerfile_uid.py
Normal file
37
tests/unit/test_dockerfile_uid.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Regression test for Dockerfile UID/GID.
|
||||
|
||||
QA-round-3 B1: the production stage of the Dockerfile must create the
|
||||
``appuser`` account with UID 1000 / GID 1000. Previously this used
|
||||
``adduser --system`` which on Debian-slim assigns UID 100 / GID 65534
|
||||
(nogroup), causing PermissionError when the scraper tried to create new
|
||||
listing directories on the NFS-backed PVC (owned 1000:1000 mode 775).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCKERFILE = REPO_ROOT / "Dockerfile"
|
||||
|
||||
|
||||
class TestDockerfileAppUser:
|
||||
"""The Dockerfile production stage must run as uid 1000 / gid 1000."""
|
||||
|
||||
def test_production_stage_creates_user_with_uid_1000(self) -> None:
|
||||
contents = DOCKERFILE.read_text()
|
||||
# The fix uses `useradd --uid 1000 --gid 1000` (and a matching
|
||||
# groupadd) instead of `adduser --system` which would assign uid 100.
|
||||
assert "--uid 1000" in contents, (
|
||||
"Dockerfile must create appuser with explicit --uid 1000 to "
|
||||
"match NFS-backed data PVC ownership"
|
||||
)
|
||||
assert "--gid 1000" in contents, (
|
||||
"Dockerfile must create appuser with explicit --gid 1000"
|
||||
)
|
||||
|
||||
def test_production_stage_does_not_use_adduser_system(self) -> None:
|
||||
"""`adduser --system` assigns uid 100 — must not be used."""
|
||||
contents = DOCKERFILE.read_text()
|
||||
assert "adduser --system" not in contents, (
|
||||
"Dockerfile must not use `adduser --system` for appuser — "
|
||||
"it assigns uid 100 which can't write to the 1000:1000 NFS mount"
|
||||
)
|
||||
|
|
@ -52,6 +52,15 @@ class TestMakeCacheKey:
|
|||
class TestGetRedisClient:
|
||||
"""Tests for _get_redis_client() URL parsing."""
|
||||
|
||||
# Keepalive kwargs added to keep connection alive across the in-cluster
|
||||
# Redis HAProxy 30s idle timeout. Same options are passed to every
|
||||
# redis.from_url call in the codebase.
|
||||
_KEEPALIVE_KWARGS = {
|
||||
"decode_responses": True,
|
||||
"socket_keepalive": True,
|
||||
"health_check_interval": 25,
|
||||
}
|
||||
|
||||
@mock.patch("services.listing_cache.redis")
|
||||
def test_default_broker_url(self, mock_redis):
|
||||
"""Uses default localhost URL when env var is not set."""
|
||||
|
|
@ -59,7 +68,7 @@ class TestGetRedisClient:
|
|||
_get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://localhost:6379/2", decode_responses=True
|
||||
"redis://localhost:6379/2", **self._KEEPALIVE_KWARGS
|
||||
)
|
||||
|
||||
@mock.patch("services.listing_cache.redis")
|
||||
|
|
@ -71,7 +80,7 @@ class TestGetRedisClient:
|
|||
_get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://myhost:1234/2", decode_responses=True
|
||||
"redis://myhost:1234/2", **self._KEEPALIVE_KWARGS
|
||||
)
|
||||
|
||||
@mock.patch("services.listing_cache.redis")
|
||||
|
|
@ -84,7 +93,7 @@ class TestGetRedisClient:
|
|||
_get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://:secret@myhost:6379/2", decode_responses=True
|
||||
"redis://:secret@myhost:6379/2", **self._KEEPALIVE_KWARGS
|
||||
)
|
||||
|
||||
@mock.patch("services.listing_cache.redis")
|
||||
|
|
@ -97,7 +106,7 @@ class TestGetRedisClient:
|
|||
_get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://myhost:6379/2?timeout=5", decode_responses=True
|
||||
"redis://myhost:6379/2?timeout=5", **self._KEEPALIVE_KWARGS
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Unit tests for tasks/listing_tasks.py."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections import deque
|
||||
|
|
@ -10,6 +11,7 @@ import tasks.listing_tasks as module
|
|||
from tasks.listing_tasks import (
|
||||
_update_task_state,
|
||||
_PipelineState,
|
||||
_process_worker,
|
||||
TaskLogHandler,
|
||||
SCRAPE_LOCK_NAME,
|
||||
LOG_BUFFER_MAX_LINES,
|
||||
|
|
@ -18,6 +20,7 @@ from tasks.listing_tasks import (
|
|||
PHASE_FETCHING,
|
||||
PHASE_PROCESSING,
|
||||
PHASE_COMPLETED,
|
||||
dump_listings_task,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -293,3 +296,174 @@ class TestPhaseConstants:
|
|||
|
||||
def test_num_workers(self):
|
||||
assert NUM_WORKERS == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for QA-round-3 backend bugs (B5, B6, B20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessWorkerExceptionHandling:
|
||||
"""B6 regression: _process_worker must keep draining the queue when a
|
||||
single listing raises an unhandled exception (e.g. PermissionError).
|
||||
Previously one bad listing aborted the entire scrape."""
|
||||
|
||||
async def test_continues_after_per_listing_exception(self):
|
||||
"""A PermissionError from one listing should not stop sibling listings."""
|
||||
# Three listings in the queue followed by a None sentinel.
|
||||
queue: asyncio.Queue[int | None] = asyncio.Queue()
|
||||
for listing_id in [1, 2, 3]:
|
||||
await queue.put(listing_id)
|
||||
await queue.put(None)
|
||||
|
||||
# Processor: listing 1 succeeds, listing 2 raises, listing 3 succeeds.
|
||||
good_listing = MagicMock()
|
||||
|
||||
async def fake_process_listing(listing_id, on_step_complete=None):
|
||||
if listing_id == 2:
|
||||
raise PermissionError("Permission denied: data/rs/2")
|
||||
return good_listing
|
||||
|
||||
processor = MagicMock()
|
||||
processor.process_listing = AsyncMock(side_effect=fake_process_listing)
|
||||
|
||||
state = _PipelineState()
|
||||
reporter = MagicMock()
|
||||
|
||||
await _process_worker(queue, processor, state, reporter)
|
||||
|
||||
# All three IDs were attempted (queue drained before exit).
|
||||
assert processor.process_listing.call_count == 3
|
||||
# Two succeeded, one failed.
|
||||
assert state.processed_count == 2
|
||||
assert state.failed_count == 1
|
||||
assert len(state.processed_listings) == 2
|
||||
|
||||
async def test_cancelled_error_propagates(self):
|
||||
"""CancelledError must NOT be swallowed — cooperative cancellation
|
||||
relies on it propagating up through asyncio.gather()."""
|
||||
queue: asyncio.Queue[int | None] = asyncio.Queue()
|
||||
await queue.put(99)
|
||||
# No sentinel — the worker should bubble the CancelledError before
|
||||
# ever getting a chance to drain further.
|
||||
|
||||
processor = MagicMock()
|
||||
processor.process_listing = AsyncMock(side_effect=asyncio.CancelledError())
|
||||
|
||||
state = _PipelineState()
|
||||
reporter = MagicMock()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _process_worker(queue, processor, state, reporter)
|
||||
|
||||
|
||||
class TestDumpListingsTaskFailurePublish:
|
||||
"""B5 regression: dump_listings_task must publish a terminal FAILURE
|
||||
event to the task_progress:<id> pub/sub channel when the underlying
|
||||
scrape raises an exception. Previously only the happy-path SUCCESS
|
||||
was published, leaving WebSocket subscribers stuck on the last
|
||||
progress packet."""
|
||||
|
||||
@patch("tasks.listing_tasks.publish_task_progress")
|
||||
@patch("tasks.listing_tasks.asyncio.run")
|
||||
@patch("tasks.listing_tasks.redis_lock")
|
||||
def test_publishes_failure_event_on_exception(
|
||||
self, mock_redis_lock, mock_asyncio_run, mock_publish
|
||||
):
|
||||
"""When dump_listings_full raises, a FAILURE event is published."""
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__ = MagicMock(return_value=True)
|
||||
mock_cm.__exit__ = MagicMock(return_value=False)
|
||||
mock_redis_lock.return_value = mock_cm
|
||||
|
||||
mock_asyncio_run.side_effect = PermissionError(
|
||||
"[Errno 13] Permission denied: 'data/rs/12345'"
|
||||
)
|
||||
|
||||
dump_listings_task.update_state = MagicMock()
|
||||
|
||||
# Force a deterministic task_id so we can assert on it.
|
||||
with patch.object(
|
||||
type(dump_listings_task),
|
||||
"request",
|
||||
new=MagicMock(id="fake-task-id"),
|
||||
create=True,
|
||||
):
|
||||
with pytest.raises(PermissionError):
|
||||
dump_listings_task.run(
|
||||
'{"listing_type": "RENT", "min_price": 1000, "max_price": 5000}'
|
||||
)
|
||||
|
||||
# Inspect publish_task_progress calls for a FAILURE event.
|
||||
failure_calls = [
|
||||
c for c in mock_publish.call_args_list
|
||||
if len(c.args) >= 2 and c.args[1] == "FAILURE"
|
||||
]
|
||||
assert failure_calls, (
|
||||
f"Expected a FAILURE publish, got: "
|
||||
f"{[c.args[1] for c in mock_publish.call_args_list if len(c.args) >= 2]}"
|
||||
)
|
||||
# The meta payload must include an error message.
|
||||
meta = failure_calls[0].args[2]
|
||||
assert "error" in meta
|
||||
assert "Permission denied" in meta["error"]
|
||||
assert meta["exc_type"] == "PermissionError"
|
||||
|
||||
|
||||
class TestDumpListingsTaskDecoratorConfig:
|
||||
"""B20 regression: dump_listings_task must have time_limit /
|
||||
soft_time_limit / acks_late configured so dead tasks reap themselves
|
||||
even after pickup."""
|
||||
|
||||
def test_task_has_time_limits(self):
|
||||
# Celery exposes these via the task attributes once decorated.
|
||||
assert dump_listings_task.time_limit == 3600
|
||||
assert dump_listings_task.soft_time_limit == 3500
|
||||
|
||||
def test_task_acks_late(self):
|
||||
assert dump_listings_task.acks_late is True
|
||||
|
||||
|
||||
class TestCeleryAppKeepaliveOptions:
|
||||
"""B4 regression: broker / result-backend transport options must
|
||||
enable TCP keepalive and a Celery-level health check so the Redis
|
||||
HAProxy in front of the in-cluster Sentinel doesn't reap idle
|
||||
connections every 30s."""
|
||||
|
||||
def test_broker_transport_options_present(self):
|
||||
from celery_app import app as celery_app
|
||||
opts = celery_app.conf.get("broker_transport_options") or {}
|
||||
assert opts.get("socket_keepalive") is True
|
||||
assert opts.get("health_check_interval") == 25
|
||||
|
||||
def test_result_backend_transport_options_present(self):
|
||||
from celery_app import app as celery_app
|
||||
opts = celery_app.conf.get("result_backend_transport_options") or {}
|
||||
assert opts.get("socket_keepalive") is True
|
||||
assert opts.get("health_check_interval") == 25
|
||||
|
||||
|
||||
class TestRedisClientKeepalive:
|
||||
"""B4 regression: every helper that creates a Redis client must
|
||||
pass socket_keepalive=True and health_check_interval=25."""
|
||||
|
||||
@patch("services.task_progress_publisher.redis")
|
||||
def test_task_progress_publisher_uses_keepalive(self, mock_redis):
|
||||
# Reset the cached client so the patch takes effect.
|
||||
import services.task_progress_publisher as m
|
||||
m._redis_client = None
|
||||
m._get_redis_client()
|
||||
mock_redis.Redis.from_url.assert_called_once()
|
||||
kwargs = mock_redis.Redis.from_url.call_args.kwargs
|
||||
assert kwargs["socket_keepalive"] is True
|
||||
assert kwargs["health_check_interval"] == 25
|
||||
m._redis_client = None # leave the module clean for other tests
|
||||
|
||||
@patch("utils.redis_lock.redis")
|
||||
def test_redis_lock_uses_keepalive(self, mock_redis):
|
||||
from utils.redis_lock import get_redis_client
|
||||
get_redis_client()
|
||||
mock_redis.from_url.assert_called_once()
|
||||
kwargs = mock_redis.from_url.call_args.kwargs
|
||||
assert kwargs["socket_keepalive"] is True
|
||||
assert kwargs["health_check_interval"] == 25
|
||||
|
|
|
|||
|
|
@ -6,69 +6,103 @@ import pytest
|
|||
from utils.redis_lock import redis_lock, get_redis_client
|
||||
|
||||
|
||||
def _setup_client(mock_get_client: mock.MagicMock, set_return: object = True) -> mock.MagicMock:
|
||||
"""Return a MagicMock redis client wired up for the lock helper."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = set_return
|
||||
mock_get_client.return_value = mock_client
|
||||
return mock_client
|
||||
|
||||
|
||||
class TestRedisLock:
|
||||
"""Tests for redis_lock context manager."""
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_acquired_successfully(self, mock_get_client):
|
||||
"""Test lock acquisition when no other lock exists."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client = _setup_client(mock_get_client)
|
||||
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is True
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=3600 * 4)
|
||||
mock_client.delete.assert_called_once_with("lock:test_lock")
|
||||
# Lock is set with the owner UUID, nx=True, and the configured TTL.
|
||||
assert mock_client.set.call_count == 1
|
||||
args, kwargs = mock_client.set.call_args
|
||||
assert args[0] == "lock:test_lock"
|
||||
assert isinstance(args[1], str) and len(args[1]) == 32 # uuid4 hex
|
||||
assert kwargs == {"nx": True, "ex": 3600 * 4}
|
||||
# Release happens via register_script (Lua CAS), not raw DEL.
|
||||
mock_client.register_script.assert_called_once()
|
||||
# The script wrapper is called once with the lock key and owner token.
|
||||
release_script = mock_client.register_script.return_value
|
||||
release_script.assert_called_once()
|
||||
call_args = release_script.call_args
|
||||
assert call_args.kwargs["keys"] == ["lock:test_lock"]
|
||||
assert call_args.kwargs["args"][0] == args[1] # same owner token
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_not_acquired(self, mock_get_client):
|
||||
"""Test lock not acquired when another lock exists."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = None # Redis returns None when nx=True fails
|
||||
mock_get_client.return_value = mock_client
|
||||
# Redis returns None when nx=True fails
|
||||
mock_client = _setup_client(mock_get_client, set_return=None)
|
||||
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is False
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=3600 * 4)
|
||||
# Should NOT call delete since we didn't acquire the lock
|
||||
mock_client.delete.assert_not_called()
|
||||
# Should NOT register or invoke the release script since we didn't acquire.
|
||||
mock_client.register_script.assert_not_called()
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_lock_released_on_exception(self, mock_get_client):
|
||||
"""Test lock is released even when exception occurs."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client = _setup_client(mock_get_client)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with redis_lock("test_lock") as acquired:
|
||||
assert acquired is True
|
||||
raise ValueError("Test error")
|
||||
|
||||
# Lock should still be released
|
||||
mock_client.delete.assert_called_once_with("lock:test_lock")
|
||||
# Lock should still be released via the Lua CAS script.
|
||||
mock_client.register_script.assert_called_once()
|
||||
mock_client.register_script.return_value.assert_called_once()
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_custom_timeout(self, mock_get_client):
|
||||
"""Test lock with custom timeout."""
|
||||
mock_client = mock.MagicMock()
|
||||
mock_client.set.return_value = True
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_client = _setup_client(mock_get_client)
|
||||
|
||||
with redis_lock("test_lock", timeout=300) as acquired:
|
||||
assert acquired is True
|
||||
|
||||
mock_client.set.assert_called_once_with("lock:test_lock", "1", nx=True, ex=300)
|
||||
# Only one SET call with the configured TTL.
|
||||
args, kwargs = mock_client.set.call_args
|
||||
assert args[0] == "lock:test_lock"
|
||||
assert kwargs == {"nx": True, "ex": 300}
|
||||
|
||||
@mock.patch("utils.redis_lock.get_redis_client")
|
||||
def test_owner_token_is_unique_per_acquisition(self, mock_get_client):
|
||||
"""Each acquisition gets a fresh UUID owner token (fencing token)."""
|
||||
mock_client = _setup_client(mock_get_client)
|
||||
|
||||
with redis_lock("test_lock"):
|
||||
pass
|
||||
token_first = mock_client.set.call_args[0][1]
|
||||
|
||||
with redis_lock("test_lock"):
|
||||
pass
|
||||
token_second = mock_client.set.call_args[0][1]
|
||||
|
||||
assert token_first != token_second
|
||||
|
||||
@mock.patch("utils.redis_lock.redis")
|
||||
def test_get_redis_client_uses_broker_url(self, mock_redis):
|
||||
"""Test Redis client is created from CELERY_BROKER_URL."""
|
||||
"""Test Redis client is created from CELERY_BROKER_URL with keepalive."""
|
||||
with mock.patch.dict("os.environ", {"CELERY_BROKER_URL": "redis://testhost:1234/5"}):
|
||||
get_redis_client()
|
||||
|
||||
mock_redis.from_url.assert_called_once_with(
|
||||
"redis://testhost:1234/5", decode_responses=True
|
||||
"redis://testhost:1234/5",
|
||||
decode_responses=True,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=25,
|
||||
)
|
||||
|
|
|
|||
96
tests/unit/test_redis_repository.py
Normal file
96
tests/unit/test_redis_repository.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Unit tests for redis_repository.RedisRepository.
|
||||
|
||||
Regression coverage for QA-round-3 B21: user state must live on a dedicated
|
||||
Redis logical DB (default db3) with all keys prefixed by ``wrongmove:user:``.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _patched_app() -> mock.MagicMock:
|
||||
"""Return a MagicMock standing in for celery_app.app.broker_connection()."""
|
||||
fake_app = mock.MagicMock()
|
||||
fake_app.broker_connection.return_value.info.return_value = {
|
||||
"hostname": "redis.test",
|
||||
"port": 6379,
|
||||
}
|
||||
return fake_app
|
||||
|
||||
|
||||
class TestRedisRepositoryDbSelection:
|
||||
"""B21 regression: RedisRepository writes to a dedicated DB (default 3)."""
|
||||
|
||||
def test_defaults_to_db_3(self):
|
||||
import redis_repository
|
||||
with mock.patch.dict("os.environ", {}, clear=False) as _env:
|
||||
_env.pop("REDIS_USER_DB", None)
|
||||
with mock.patch.object(redis_repository, "redis") as mock_redis, \
|
||||
mock.patch.object(redis_repository, "app", _patched_app()):
|
||||
redis_repository.RedisRepository()
|
||||
|
||||
kwargs = mock_redis.Redis.call_args.kwargs
|
||||
assert kwargs["db"] == 3
|
||||
assert kwargs["socket_keepalive"] is True
|
||||
assert kwargs["health_check_interval"] == 25
|
||||
|
||||
def test_honours_REDIS_USER_DB_env_var(self):
|
||||
import redis_repository
|
||||
with mock.patch.dict("os.environ", {"REDIS_USER_DB": "7"}):
|
||||
with mock.patch.object(redis_repository, "redis") as mock_redis, \
|
||||
mock.patch.object(redis_repository, "app", _patched_app()):
|
||||
redis_repository.RedisRepository()
|
||||
|
||||
kwargs = mock_redis.Redis.call_args.kwargs
|
||||
assert kwargs["db"] == 7
|
||||
|
||||
|
||||
class TestRedisRepositoryKeyPrefix:
|
||||
"""B21 regression: all keys written via set_key/get_key are namespaced
|
||||
with the ``wrongmove:user:`` prefix to avoid collisions on a shared
|
||||
Redis instance."""
|
||||
|
||||
def _fresh_repo(self) -> tuple[object, mock.MagicMock]:
|
||||
import redis_repository
|
||||
with mock.patch.object(redis_repository, "redis"), \
|
||||
mock.patch.object(redis_repository, "app", _patched_app()):
|
||||
repo = redis_repository.RedisRepository()
|
||||
fake_client = mock.MagicMock()
|
||||
repo.redis_client = fake_client
|
||||
return repo, fake_client
|
||||
|
||||
def test_set_key_prepends_prefix(self):
|
||||
repo, fake_client = self._fresh_repo()
|
||||
repo.set_key("webauthn:challenge:abc", {"x": 1})
|
||||
# First positional arg to .set is the key.
|
||||
set_call_key = fake_client.set.call_args.args[0]
|
||||
assert set_call_key == "wrongmove:user:webauthn:challenge:abc"
|
||||
# expire uses the same prefixed key.
|
||||
expire_call_key = fake_client.expire.call_args.args[0]
|
||||
assert expire_call_key == "wrongmove:user:webauthn:challenge:abc"
|
||||
|
||||
def test_get_key_uses_prefix(self):
|
||||
repo, fake_client = self._fresh_repo()
|
||||
fake_client.get.return_value = '{"hello": "world"}'
|
||||
result = repo.get_key("webauthn:challenge:abc")
|
||||
fake_client.get.assert_called_once_with(
|
||||
"wrongmove:user:webauthn:challenge:abc"
|
||||
)
|
||||
assert result == {"hello": "world"}
|
||||
|
||||
def test_does_not_double_prefix_already_prefixed_key(self):
|
||||
repo, fake_client = self._fresh_repo()
|
||||
repo.set_key("wrongmove:user:already-prefixed", {"x": 1})
|
||||
set_call_key = fake_client.set.call_args.args[0]
|
||||
assert set_call_key == "wrongmove:user:already-prefixed"
|
||||
|
||||
def test_add_task_for_user_uses_prefixed_key(self):
|
||||
repo, fake_client = self._fresh_repo()
|
||||
fake_client.get.return_value = None # no prior tasks
|
||||
from api.auth import User
|
||||
user = User(sub="", email="test@example.com", name="")
|
||||
repo.add_task_for_user(user, "task-xyz")
|
||||
# The redis SET should target the namespaced key.
|
||||
set_call_key = fake_client.set.call_args.args[0]
|
||||
assert set_call_key == "wrongmove:user:test@example.com/tasks"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""Redis-based distributed locking for task coordination."""
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
|
|
@ -8,18 +9,35 @@ import redis
|
|||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Lua compare-and-delete script: only DEL the key if its current value
|
||||
# matches our owner token. This prevents a process that lost the lock
|
||||
# (e.g. via TTL expiry) from accidentally releasing a different acquirer's
|
||||
# lock.
|
||||
_RELEASE_SCRIPT = (
|
||||
"if redis.call('GET', KEYS[1]) == ARGV[1] then "
|
||||
"return redis.call('DEL', KEYS[1]) "
|
||||
"else return 0 end"
|
||||
)
|
||||
|
||||
|
||||
def get_redis_client() -> redis.Redis:
|
||||
"""Get Redis client from Celery broker URL."""
|
||||
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
return redis.from_url(broker_url, decode_responses=True)
|
||||
# socket_keepalive + health_check_interval keep the connection alive
|
||||
# across the Redis HAProxy 30s idle timeout (see celery_app.py).
|
||||
return redis.from_url(
|
||||
broker_url,
|
||||
decode_responses=True,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=25,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def redis_lock(
|
||||
lock_name: str, timeout: int = 3600 * 4
|
||||
) -> Generator[bool, None, None]:
|
||||
"""Distributed lock using Redis.
|
||||
"""Distributed lock using Redis with an owner-fencing token.
|
||||
|
||||
Args:
|
||||
lock_name: Unique name for the lock
|
||||
|
|
@ -37,14 +55,23 @@ def redis_lock(
|
|||
"""
|
||||
client = get_redis_client()
|
||||
lock_key = f"lock:{lock_name}"
|
||||
# Per-acquirer fencing token: only the holder can release the lock.
|
||||
owner_token = uuid.uuid4().hex
|
||||
|
||||
# Try to acquire the lock
|
||||
acquired = client.set(lock_key, "1", nx=True, ex=timeout)
|
||||
# Try to acquire the lock; store the owner token as the value.
|
||||
acquired = client.set(lock_key, owner_token, nx=True, ex=timeout)
|
||||
|
||||
try:
|
||||
yield bool(acquired)
|
||||
finally:
|
||||
# Release the lock only if we acquired it
|
||||
# Release the lock only if we acquired it AND we still own it.
|
||||
# The Lua compare-and-delete guards against the case where our TTL
|
||||
# expired and a different process picked up the lock; we won't
|
||||
# accidentally delete their key.
|
||||
if acquired:
|
||||
client.delete(lock_key)
|
||||
logger.info(f"Released lock: {lock_name}")
|
||||
try:
|
||||
release = client.register_script(_RELEASE_SCRIPT)
|
||||
release(keys=[lock_key], args=[owner_token])
|
||||
logger.info(f"Released lock: {lock_name}")
|
||||
except redis.RedisError as e:
|
||||
logger.warning(f"Failed to release lock {lock_name}: {e}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue