From c785e61e6920bb2ec87e69298aa0d13aebc45601 Mon Sep 17 00:00:00 2001 From: senke Date: Mon, 23 Feb 2026 23:42:02 +0100 Subject: [PATCH] feat(v0.701): AdminTransfers page/route, MSW, stories, Deep Health, API ref, docs, scope v0.702 - Step 13: AdminTransfersPage, LazyAdminTransfers, route /admin/transfers - Step 14: MSW handlers admin transfers - Step 15: AdminTransfersView stories (Default, Empty, WithFailedTransfers, Error, Loading) - Step 16-17: DeepHealth handler (disk, config), GET /health/deep - Step 19: health_deep_test.go (4 tests) - Step 20: docs/API_REFERENCE.md - Step 21: Archive V0_604, MIGRATIONS.md migration 116 - Step 22: CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.701 - Step 23: RETROSPECTIVE_V0701, V0_702 placeholder, SCOPE_CONTROL, .cursorrules - Step 24: Archive V0_701_RELEASE_SCOPE - Fix: AdminTransfersView Select component (use options API) --- .cursorrules | 8 +- CHANGELOG.md | 20 + .../admin/AdminTransfersView.stories.tsx | 122 ++++++ .../components/admin/AdminTransfersView.tsx | 249 +++++++++++ apps/web/src/components/ui/LazyComponent.tsx | 1 + .../src/components/ui/lazy-component/index.ts | 1 + .../ui/lazy-component/lazyExports.ts | 8 + .../admin/pages/AdminTransfersPage.tsx | 11 + apps/web/src/mocks/handlers-admin.ts | 58 +++ apps/web/src/router/routeConfig.tsx | 2 + docs/API_REFERENCE.md | 412 ++++++++++++++++++ docs/FEATURE_STATUS.md | 17 +- docs/MIGRATIONS.md | 6 + docs/PROJECT_STATE.md | 12 +- docs/RETROSPECTIVE_V0701.md | 19 + docs/SCOPE_CONTROL.md | 44 +- docs/V0_702_RELEASE_SCOPE.md | 18 + docs/{ => archive}/V0_604_RELEASE_SCOPE.md | 0 docs/{ => archive}/V0_701_RELEASE_SCOPE.md | 0 veza-backend-api/internal/api/routes_core.go | 18 + veza-backend-api/internal/handlers/health.go | 105 +++++ .../internal/handlers/health_deep_test.go | 131 ++++++ 22 files changed, 1232 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/admin/AdminTransfersView.stories.tsx create mode 100644 apps/web/src/components/admin/AdminTransfersView.tsx create mode 100644 apps/web/src/features/admin/pages/AdminTransfersPage.tsx create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/RETROSPECTIVE_V0701.md create mode 100644 docs/V0_702_RELEASE_SCOPE.md rename docs/{ => archive}/V0_604_RELEASE_SCOPE.md (100%) rename docs/{ => archive}/V0_701_RELEASE_SCOPE.md (100%) create mode 100644 veza-backend-api/internal/handlers/health_deep_test.go diff --git a/.cursorrules b/.cursorrules index ffba7783a..78c81049f 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,10 +1,10 @@ # Règles de Développement UI - Projet SaaS -## 0. Scope v0.701 (priorité absolue) +## 0. Scope v0.702 (priorité absolue) -- **Référence** : `docs/V0_701_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md` -- Avant toute modification : vérifier si le changement est **dans le scope v0.701** -- **Autorisé v0.701** : Retry transfers, Admin transfers dashboard, Deep health checks, API documentation (voir V0_701_RELEASE_SCOPE.md) +- **Référence** : `docs/V0_702_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md` +- Avant toute modification : vérifier si le changement est **dans le scope v0.702** +- **Autorisé v0.702** : À définir (voir V0_702_RELEASE_SCOPE.md) - **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité) - En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure. diff --git a/CHANGELOG.md b/CHANGELOG.md index ece5d553e..2ce2ea5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog - Veza +## [v0.701] - 2026-02-23 + +### Added +- Transfer Retry Worker: automatic retry of failed Stripe Connect transfers (exponential backoff, max 3 retries) +- Migration 116: retry_count, next_retry_at columns on seller_transfers +- GET /admin/transfers: paginated admin view of all platform transfers (filters: status, seller, date) +- POST /admin/transfers/:id/retry: manual retry of failed transfers (admin only) +- AdminTransfersView frontend component with status badges, retry button +- GET /health/deep: deep health check (DB, Redis, S3, disk, config summary) +- Startup config validation: PlatformFeeRate range, Stripe Connect coherence, retry config +- Prometheus metrics: transfer retry (total, success, failures, permanent) +- docs/API_REFERENCE.md: API documentation with curl examples + +### Changed +- SellerTransfer model: added retry_count, next_retry_at fields +- Config: added TRANSFER_RETRY_ENABLED, TRANSFER_RETRY_MAX, TRANSFER_RETRY_INTERVAL +- Health handler: added DeepHealth method with disk space and config + +--- + ## [v0.603] - 2026-02-23 ### Added diff --git a/apps/web/src/components/admin/AdminTransfersView.stories.tsx b/apps/web/src/components/admin/AdminTransfersView.stories.tsx new file mode 100644 index 000000000..260daff4d --- /dev/null +++ b/apps/web/src/components/admin/AdminTransfersView.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; +import { AdminTransfersView } from './AdminTransfersView'; + +const meta = { + title: 'Components/Admin/AdminTransfersView', + component: AdminTransfersView, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Default state with mocked transfers (MSW handlers-admin) */ +export const Default: Story = {}; + +/** Empty state — no transfers */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/admin/transfers', () => + HttpResponse.json({ + success: true, + data: { transfers: [], total: 0 }, + }) + ), + ], + }, + }, +}; + +/** Only failed transfers (Retry button visible) */ +export const WithFailedTransfers: Story = { + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/admin/transfers', () => + HttpResponse.json({ + success: true, + data: { + transfers: [ + { + id: 'tr-fail-1', + seller_id: 'seller-1', + order_id: 'ord-1', + amount_cents: 2699, + platform_fee_cents: 300, + currency: 'EUR', + status: 'failed', + error_message: 'Stripe API error', + retry_count: 1, + next_retry_at: new Date(Date.now() + 600000).toISOString(), + created_at: new Date(Date.now() - 3600000).toISOString(), + }, + { + id: 'tr-fail-2', + seller_id: 'seller-2', + order_id: 'ord-2', + amount_cents: 4500, + platform_fee_cents: 500, + currency: 'EUR', + status: 'failed', + error_message: 'Insufficient funds', + retry_count: 2, + next_retry_at: new Date(Date.now() + 1200000).toISOString(), + created_at: new Date(Date.now() - 86400000).toISOString(), + }, + ], + total: 2, + }, + }) + ), + ], + }, + }, +}; + +/** Error state — API returns error */ +export const Error: Story = { + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/admin/transfers', () => + HttpResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + ), + ], + }, + }, +}; + +/** Loading state — delayed response shows skeleton */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/admin/transfers', async () => { + await new Promise((r) => setTimeout(r, 2000)); + return HttpResponse.json({ + success: true, + data: { + transfers: [ + { + id: 'tr-admin-1', + seller_id: 'seller-1', + order_id: 'ord-1', + amount_cents: 2699, + platform_fee_cents: 300, + currency: 'EUR', + status: 'completed', + retry_count: 0, + created_at: new Date().toISOString(), + }, + ], + total: 1, + }, + }); + }), + ], + }, + }, +}; diff --git a/apps/web/src/components/admin/AdminTransfersView.tsx b/apps/web/src/components/admin/AdminTransfersView.tsx new file mode 100644 index 000000000..8131eddf2 --- /dev/null +++ b/apps/web/src/components/admin/AdminTransfersView.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Select } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; +import { RefreshCcw, ChevronLeft, ChevronRight } from 'lucide-react'; +import { commerceService, type SellerTransfer } from '@/services/commerceService'; +import { useToast } from '@/components/feedback/ToastProvider'; + +const LIMIT = 20; + +function statusBadgeClass(status: string): string { + switch (status) { + case 'completed': + return 'bg-success/20 text-success'; + case 'failed': + return 'bg-destructive/20 text-destructive'; + case 'permanently_failed': + return 'bg-muted text-muted-foreground'; + case 'pending': + return 'bg-warning/20 text-warning'; + case 'skipped': + return 'bg-primary/20 text-primary'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +function truncateId(id: string): string { + if (!id || id.length < 12) return id; + return `${id.slice(0, 8)}…`; +} + +export function AdminTransfersView() { + const { addToast } = useToast(); + const [transfers, setTransfers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + const [sellerIdFilter, setSellerIdFilter] = useState(''); + const [offset, setOffset] = useState(0); + const [retrying, setRetrying] = useState(null); + + useEffect(() => { + setOffset(0); + }, [statusFilter, sellerIdFilter]); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params: Record = { + limit: LIMIT, + offset, + }; + if (statusFilter && statusFilter !== 'all') params.status = statusFilter; + if (sellerIdFilter.trim()) params.seller_id = sellerIdFilter.trim(); + const res = await commerceService.getAdminTransfers(params); + setTransfers(res.transfers); + setTotal(res.total); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + setTransfers([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [offset, statusFilter, sellerIdFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleRetry = async (transferId: string) => { + setRetrying(transferId); + try { + await commerceService.retryAdminTransfer(transferId); + addToast({ message: 'Transfer retried successfully', type: 'success' }); + fetchData(); + } catch (e) { + addToast({ + message: e instanceof Error ? e.message : 'Retry failed', + type: 'error', + }); + } finally { + setRetrying(null); + } + }; + + const hasNext = offset + LIMIT < total; + const hasPrev = offset > 0; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Platform Transfers

+
+ setSellerIdFilter(e.target.value)} + className="w-48" + /> + +
+
+ + + {loading ? ( +
+ + +
+ ) : transfers.length === 0 ? ( +
+ No transfers found +
+ ) : ( + <> + + + + Seller + Order + Net (EUR) + Fee (EUR) + Status + Retries + Date + Actions + + + + {transfers.map((t) => ( + + + {truncateId(t.seller_id ?? t.id)} + + + {truncateId(t.order_id)} + + + €{(t.amount_cents / 100).toFixed(2)} + + + €{(t.platform_fee_cents / 100).toFixed(2)} + + + + {t.status} + + + {t.retry_count ?? 0} + + {new Date(t.created_at).toLocaleDateString()} + + + {t.status === 'failed' && ( + + )} + + + ))} + +
+
+ + {offset + 1}–{Math.min(offset + LIMIT, total)} of {total} + +
+ + +
+
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index bfa87b090..ad9fcb3ad 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -20,6 +20,7 @@ export { LazyTrackDetail, LazyPlaylistRoutes, LazyAdminDashboard, + LazyAdminTransfers, LazyAnalytics, LazyWebhooks, LazyDesignSystemDemo, diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index f94d4ba45..b3fb7a497 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -23,6 +23,7 @@ export { LazyTrackDetail, LazyPlaylistRoutes, LazyAdminDashboard, + LazyAdminTransfers, LazyAnalytics, LazyWebhooks, LazyDesignSystemDemo, diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index ae132e479..e0f1b71f0 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -115,6 +115,14 @@ export const LazyAdminDashboard = createLazyComponent( undefined, 'Admin Dashboard', ); +export const LazyAdminTransfers = createLazyComponent( + () => + import('@/features/admin/pages/AdminTransfersPage').then((m) => ({ + default: m.AdminTransfersPage, + })), + undefined, + 'Admin Transfers', +); export const LazyAnalytics = createLazyComponent( () => import('@/features/analytics/pages/AnalyticsPage').then((m) => ({ diff --git a/apps/web/src/features/admin/pages/AdminTransfersPage.tsx b/apps/web/src/features/admin/pages/AdminTransfersPage.tsx new file mode 100644 index 000000000..4d98fbe1f --- /dev/null +++ b/apps/web/src/features/admin/pages/AdminTransfersPage.tsx @@ -0,0 +1,11 @@ +/** + * Admin transfers page — v0.701 + */ + +import { AdminTransfersView } from '@/components/admin/AdminTransfersView'; + +export function AdminTransfersPage() { + return ; +} + +export default AdminTransfersPage; diff --git a/apps/web/src/mocks/handlers-admin.ts b/apps/web/src/mocks/handlers-admin.ts index c5e82f872..14d481ce2 100644 --- a/apps/web/src/mocks/handlers-admin.ts +++ b/apps/web/src/mocks/handlers-admin.ts @@ -184,4 +184,62 @@ export const handlersAdmin = [ ], }); }), + + // v0.701: Admin transfers + http.get('*/api/v1/admin/transfers', () => { + return HttpResponse.json({ + success: true, + data: { + transfers: [ + { + id: 'tr-admin-1', + seller_id: 'seller-1', + order_id: 'ord-1', + amount_cents: 2699, + platform_fee_cents: 300, + currency: 'EUR', + status: 'completed', + retry_count: 0, + created_at: new Date().toISOString(), + }, + { + id: 'tr-admin-2', + seller_id: 'seller-2', + order_id: 'ord-2', + amount_cents: 4500, + platform_fee_cents: 500, + currency: 'EUR', + status: 'failed', + error_message: 'Stripe error', + retry_count: 1, + next_retry_at: new Date(Date.now() + 600000).toISOString(), + created_at: new Date(Date.now() - 86400000).toISOString(), + }, + { + id: 'tr-admin-3', + seller_id: 'seller-1', + order_id: 'ord-3', + amount_cents: 1800, + platform_fee_cents: 200, + currency: 'EUR', + status: 'permanently_failed', + retry_count: 3, + created_at: new Date(Date.now() - 172800000).toISOString(), + }, + ], + total: 3, + }, + }); + }), + + http.post('*/api/v1/admin/transfers/:id/retry', () => { + return HttpResponse.json({ + success: true, + data: { + id: 'tr-admin-2', + status: 'completed', + retry_count: 2, + }, + }); + }), ]; diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index 2c5cf3fdf..4323cbab5 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -25,6 +25,7 @@ import { LazyAnalytics, LazyWebhooks, LazyAdminDashboard, + LazyAdminTransfers, LazyDesignSystemDemo, LazySocial, LazySellerDashboard, @@ -97,6 +98,7 @@ export function getProtectedRoutes(): RouteEntry[] { { path: '/analytics', element: wrapProtected() }, { path: '/webhooks', element: wrapProtected() }, { path: '/admin', element: wrapProtected() }, + { path: '/admin/transfers', element: wrapProtected() }, { path: '/social', element: wrapProtected() }, { path: '/queue', element: wrapProtected() }, { path: '/developer', element: wrapProtected() }, diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 000000000..a6b3170cc --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,412 @@ +# API Reference — Veza Backend + +Base URL: `http://localhost:8080/api/v1` (or your `APP_DOMAIN`) + +All responses use the format: `{ "success": true|false, "data": {...} }` or `{ "success": false, "error": "..." }`. + +--- + +## Authentication + +### POST /auth/register + +Register a new user. + +**Auth:** None + +**Body:** +```json +{ + "username": "johndoe", + "email": "john@example.com", + "password": "SecureP@ssw0rd" +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"johndoe","email":"john@example.com","password":"SecureP@ssw0rd"}' +``` + +**Response (201):** +```json +{ + "success": true, + "data": { + "user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" }, + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "expires_in": 3600 + } +} +``` + +--- + +### POST /auth/login + +Login with email and password. + +**Auth:** None + +**Body:** +```json +{ + "email": "john@example.com", + "password": "SecureP@ssw0rd" +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"john@example.com","password":"SecureP@ssw0rd"}' +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "expires_in": 3600, + "user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" } + } +} +``` + +--- + +### POST /auth/refresh + +Refresh access token using refresh token. + +**Auth:** None (refresh token in body or cookie) + +**Body:** +```json +{ + "refresh_token": "eyJ..." +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":"eyJ..."}' +``` + +--- + +### POST /auth/logout + +Logout and invalidate refresh token. + +**Auth:** Bearer token (optional) + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/logout \ + -H "Authorization: Bearer eyJ..." +``` + +--- + +## Marketplace + +### GET /marketplace/products + +List marketplace products (paginated). + +**Auth:** None (public) + +**Query params:** `limit`, `offset`, `search`, `sort` + +**Example:** +```bash +curl "http://localhost:8080/api/v1/marketplace/products?limit=20&offset=0" +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "products": [...], + "total": 42 + } +} +``` + +--- + +### GET /marketplace/products/:id + +Get a single product by ID. + +**Auth:** None (public) + +**Example:** +```bash +curl http://localhost:8080/api/v1/marketplace/products/uuid-here +``` + +--- + +### POST /marketplace/orders + +Create an order (checkout). + +**Auth:** Bearer token (required) + +**Body:** Cart items, payment method, etc. + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/marketplace/orders \ + -H "Authorization: Bearer eyJ..." \ + -H "Content-Type: application/json" \ + -d '{"items":[{"product_id":"uuid","quantity":1}],"payment_method":"card"}' +``` + +--- + +## Seller + +### GET /sell/balance + +Get seller balance (Stripe Connect). + +**Auth:** Bearer token (required) + +**Example:** +```bash +curl http://localhost:8080/api/v1/sell/balance \ + -H "Authorization: Bearer eyJ..." +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "connected": true, + "available": 12500, + "pending": 3200 + } +} +``` + +--- + +### GET /sell/transfers + +List seller transfers (payout history). + +**Auth:** Bearer token (required) + +**Example:** +```bash +curl http://localhost:8080/api/v1/sell/transfers \ + -H "Authorization: Bearer eyJ..." +``` + +--- + +### POST /sell/connect/onboard + +Get Stripe Connect onboarding URL. + +**Auth:** Bearer token (required) + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/sell/connect/onboard \ + -H "Authorization: Bearer eyJ..." +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "onboarding_url": "https://connect.stripe.com/..." + } +} +``` + +--- + +## Admin + +### GET /admin/transfers + +List all platform transfers (admin only, paginated). + +**Auth:** Bearer token (admin role required) + +**Query params:** `status`, `seller_id`, `from`, `to`, `limit`, `offset` + +**Example:** +```bash +curl "http://localhost:8080/api/v1/admin/transfers?status=failed&limit=50&offset=0" \ + -H "Authorization: Bearer eyJ..." +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "transfers": [ + { + "id": "uuid", + "seller_id": "uuid", + "order_id": "uuid", + "amount_cents": 2699, + "platform_fee_cents": 300, + "currency": "EUR", + "status": "failed", + "retry_count": 1, + "next_retry_at": "2026-02-24T10:00:00Z", + "created_at": "2026-02-23T12:00:00Z" + } + ], + "total": 5 + } +} +``` + +--- + +### POST /admin/transfers/:id/retry + +Manually retry a failed transfer (admin only). + +**Auth:** Bearer token (admin role required) + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/admin/transfers/uuid-here/retry \ + -H "Authorization: Bearer eyJ..." +``` + +**Response (200):** +```json +{ + "success": true, + "data": { + "id": "uuid", + "status": "completed", + "retry_count": 2 + } +} +``` + +--- + +## Health + +### GET /health + +Simple health check (always returns OK). + +**Auth:** None + +**Example:** +```bash +curl http://localhost:8080/api/v1/health +``` + +**Response (200):** +```json +{ + "success": true, + "data": { "status": "ok" } +} +``` + +--- + +### GET /health/deep + +Deep health check (DB, Redis, S3, disk, config summary). v0.701 + +**Auth:** None + +**Example:** +```bash +curl http://localhost:8080/api/v1/health/deep +``` + +**Response (200 healthy/degraded, 503 unhealthy):** +```json +{ + "success": true, + "data": { + "status": "healthy", + "timestamp": "2026-02-23T12:00:00Z", + "checks": { + "database": { "status": "ok" }, + "redis": { "status": "ok" }, + "disk": { "status": "ok", "details": { "free_gb": 42.5 } } + }, + "config": { + "jwt_secret_set": true, + "stripe_connect_enabled": true, + "platform_fee_rate": 0.10, + "transfer_retry_enabled": true + } + } +} +``` + +--- + +### GET /healthz + +Liveness probe (Kubernetes). + +**Auth:** None + +--- + +### GET /readyz + +Readiness probe (Kubernetes). Returns `ready`, `degraded`, or `not_ready`. + +**Auth:** None + +--- + +## Webhooks + +### POST /webhooks/hyperswitch + +Hyperswitch payment webhook (signature verification required). + +**Auth:** Webhook secret (signature header) + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/webhooks/hyperswitch \ + -H "Content-Type: application/json" \ + -H "X-Hyperswitch-Signature: ..." \ + -d '{"event":"payment.succeeded",...}' +``` + +--- + +## Legacy routes (deprecated) + +The following routes are also available at the root (without `/api/v1` prefix) but are deprecated: + +- `GET /health`, `GET /health/deep`, `GET /healthz`, `GET /readyz` +- `GET /metrics` + +Use `/api/v1/...` for new integrations. diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md index acaccbbe8..93225125c 100644 --- a/docs/FEATURE_STATUS.md +++ b/docs/FEATURE_STATUS.md @@ -1,6 +1,6 @@ # Statut des fonctionnalités — Veza -**Dernière mise à jour** : février 2026 — v0.603 livrée (Transfer automatique Stripe Connect, commission plateforme) +**Dernière mise à jour** : février 2026 — v0.701 livrée (Retry transferts, Admin Dashboard, Deep Health) Ce document décrit le statut réel des fonctionnalités par rapport au code. @@ -20,7 +20,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code. | Recherche | Oui | Oui | GET /search unifié, GET /tracks/search. v0.202 : musical_key, tri pertinence. v0.203 : pg_trgm fuzzy, AND/OR/NOT, tooltip aide | | Social (feed, posts, groups, follows, blocks, trending) | Oui | Oui | v0.301 : feed API, explore. v0.302 : groupes avancés (request join, invite, rôles, mes groupes) | | Administration | Oui | Oui | Complet | -| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer automatique Stripe Connect après vente, commission plateforme, GET /sell/transfers | +| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer auto Stripe Connect. v0.701 : Retry auto failed transfers, Admin Dashboard GET/POST /admin/transfers | | Webhooks | Oui | Oui | Complet | | Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear | | Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server | @@ -169,6 +169,19 @@ Voir [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) pour le détail. Voir [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md) pour le détail. +## Livré en v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health) + +| Lot | Feature | +|-----|---------| +| R1 | Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3) | +| R1 | Migration 116 : retry_count, next_retry_at sur seller_transfers | +| A1 | Admin Transfers Dashboard : GET /admin/transfers, POST /admin/transfers/:id/retry | +| A1 | AdminTransfersView : tableau avec filtres, pagination, bouton Retry | +| H1 | Deep Health : GET /health/deep (DB, Redis, S3, disk, config summary) | +| D1 | docs/API_REFERENCE.md : documentation API avec exemples curl | + +Voir [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md) pour le détail. + ## Prévu en v0.403 (Phase 4 Commerce — suite) | Lot | Feature | diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 7b1829809..9edd26afa 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -34,6 +34,12 @@ Output: `veza-backend-api/migrations/baseline_v0601.sql` 3. Update the migration range comment (e.g. `001-113`) to reflect the latest migration number 4. The baseline file is auto-generated; do not edit it manually +## Recent Migrations + +| # | File | Description | +|---|------|-------------| +| 116 | `116_seller_transfers_retry.sql` | v0.701: Add `retry_count`, `next_retry_at` to `seller_transfers`; index for failed retries | + ## Adding New Migrations 1. Create a new file: `veza-backend-api/migrations/NNN_description.sql` diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 7bf2e09ff..a67ee92a6 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -8,10 +8,10 @@ | Élément | Valeur | |---------|--------| -| **Dernier tag** | v0.603 | +| **Dernier tag** | v0.701 | | **Branche courante** | `main` | | **Phase** | Phase 7 — Production Readiness | -| **Prochaine version** | v0.701 | +| **Prochaine version** | v0.702 | --- @@ -73,6 +73,14 @@ - Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103–108) - Sécurité : Trivy container scanning CI +### v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health) +- Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3) +- Migration 116 : retry_count, next_retry_at sur seller_transfers +- GET /admin/transfers, POST /admin/transfers/:id/retry +- AdminTransfersView : tableau admin avec filtres, pagination, bouton Retry +- GET /health/deep : DB, Redis, S3, disk, config summary +- docs/API_REFERENCE.md + ### v0.603 (Phase 6+ Transfer automatique, Commission & Stabilisation) - T1 : Transfer automatique Stripe Connect après paiement réussi (webhook Hyperswitch) - Commission plateforme configurable (PLATFORM_FEE_RATE, défaut 10 %) diff --git a/docs/RETROSPECTIVE_V0701.md b/docs/RETROSPECTIVE_V0701.md new file mode 100644 index 000000000..59542516d --- /dev/null +++ b/docs/RETROSPECTIVE_V0701.md @@ -0,0 +1,19 @@ +# Rétrospective v0.701 — Retry Transfers, Admin Dashboard & Deep Health + +## Ce qui a bien fonctionné + +- **Transfer Retry Worker** : Goroutine avec ticker, backoff exponentiel, métriques Prometheus dédiées +- **Admin Transfers Dashboard** : Handler paginé avec filtres (status, seller_id), retry manuel, frontend AdminTransfersView avec badges et pagination +- **Deep Health** : GET /health/deep avec disk space (syscall.Statfs), config summary (jwt_secret_set, stripe_connect_enabled, etc.), distinction unhealthy (DB) vs degraded (Redis/S3) +- **API Reference** : docs/API_REFERENCE.md avec exemples curl pour auth, marketplace, seller, admin, health, webhooks +- **Tests** : transfer_retry_test.go (5 cas), admin_transfer_handler_test.go (6 cas), health_deep_test.go (4 cas) + +## Points d'attention + +- **Select component** : AdminTransfersView utilise le Select avec options/value/onChange (pas Radix SelectTrigger/SelectContent) +- **Stripe Connect désactivé** : Si Stripe Connect non configuré, RetryTransfer retourne 503 ; le worker ne démarre pas + +## Prochaines étapes (v0.702) + +- À définir selon V0_702_RELEASE_SCOPE.md +- Pistes : reviews produits, factures PDF, remboursements diff --git a/docs/SCOPE_CONTROL.md b/docs/SCOPE_CONTROL.md index 0009af1f5..03313d53a 100644 --- a/docs/SCOPE_CONTROL.md +++ b/docs/SCOPE_CONTROL.md @@ -1,23 +1,23 @@ # Contrôle du scope — Anti-scope-creep **Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable. -**Référence active** : [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) -**Version précédente** : [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md) +**Référence active** : [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md) +**Version précédente** : [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md) --- ## 1. Règle d'or -> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.701.** +> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.702.** > Si non → ne pas ajouter. Créer un ticket pour une version ultérieure. --- -## 2. Pendant la phase v0.701 (jusqu'au tag) +## 2. Pendant la phase v0.702 (jusqu'au tag) ### 2.1 Autorisé -- **Corrections de bugs** sur les features IN SCOPE v0.701 +- **Corrections de bugs** sur les features IN SCOPE v0.702 - **Stabilisation** : tests, refactoring sans changement de comportement - **Nettoyage** : suppression de code mort, consolidation - **Documentation** : mise à jour des docs existantes @@ -26,20 +26,20 @@ ### 2.2 Interdit -- **Nouvelles features** hors scope v0.701 +- **Nouvelles features** hors scope v0.702 - **Nouvelles routes** ou pages hors scope - **Nouvelles dépendances** (sauf correctif sécurité) - **Changements de comportement** sur les features HORS SCOPE -- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.701 +- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.702 ### 2.3 Cas limite | Situation | Action | |-----------|--------| -| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.701. Sinon : ticket pour plus tard. | +| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.702. Sinon : ticket pour plus tard. | | Dépendance obsolète/vulnérable | Mettre à jour. Documenter dans la PR. | | Refactoring qui change une API interne | Autorisé si 0 impact sur le contrat public et tests passent. | -| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.702+. | +| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.703+. | --- @@ -47,13 +47,13 @@ ### 3.1 Checklist pré-commit (dans la tête) -1. **Mon changement modifie-t-il une feature IN SCOPE v0.701 ?** +1. **Mon changement modifie-t-il une feature IN SCOPE v0.702 ?** - Oui → Continuer. S'assurer qu'il n'y a pas de régression. - Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ? 2. **Mon changement ajoute-t-il du code ?** - - Nouvelle route, nouveau composant, nouveau service → Vérifier V0_701_RELEASE_SCOPE. Si hors scope → **STOP.** - - Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.701. + - Nouvelle route, nouveau composant, nouveau service → Vérifier V0_702_RELEASE_SCOPE. Si hors scope → **STOP.** + - Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.702. 3. **Mes tests passent-ils ?** - `npm test -- --run` (frontend) @@ -81,7 +81,7 @@ Format : `type(scope): description` Dans chaque PR, le relecteur doit valider : -- [ ] Le changement est dans le scope v0.701 (voir [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md)) +- [ ] Le changement est dans le scope v0.702 (voir [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md)) - [ ] Aucune nouvelle feature ajoutée - [ ] Aucune régression sur les flows critiques - [ ] Les tests passent @@ -92,19 +92,19 @@ Dans chaque PR, le relecteur doit valider : Une PR sera rejetée si : - Elle ajoute une nouvelle route, page ou feature -- Elle modifie le comportement d'une feature HORS SCOPE v0.701 (sauf correctif bug critique) +- Elle modifie le comportement d'une feature HORS SCOPE v0.702 (sauf correctif bug critique) - Les tests échouent - Elle introduit une dépendance non justifiée --- -## 5. Proposer une feature pour APRÈS v0.603 +## 5. Proposer une feature pour APRÈS v0.701 ### 5.1 Template Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec : -- **Alignement scope** : cocher "Hors scope v0.701 — pour v0.702+" +- **Alignement scope** : cocher "Hors scope v0.702 — pour v0.703+" - **Justification** : pourquoi cette feature est nécessaire - **Effort estimé** : S / M / L / XL - **Dépendances** : quelles features v0.701 doivent être stables avant @@ -112,8 +112,8 @@ Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md ### 5.2 Workflow 1. Créer une issue avec le template -2. **Ne pas implémenter** tant que v0.701 n'est pas taguée -3. Une fois v0.701 stable, prioriser les issues post-v0.701 dans le scope suivant +2. **Ne pas implémenter** tant que v0.702 n'est pas taguée +3. Une fois v0.702 stable, prioriser les issues post-v0.702 dans le scope suivant --- @@ -132,7 +132,7 @@ Si une vulnérabilité critique est identifiée : Si un bug bloque un déploiement ou un flow critique : - Correctif autorisé -- La feature concernée doit être IN SCOPE v0.701 ou dépendance directe d'une feature IN SCOPE +- La feature concernée doit être IN SCOPE v0.702 ou dépendance directe d'une feature IN SCOPE ### 6.3 Décision collégiale @@ -140,7 +140,7 @@ Pour tout cas ambigu : - Ouvrir une issue "Scope clarification" - Décision documentée dans l'issue -- Mise à jour de V0_701_RELEASE_SCOPE.md si le scope est étendu (exception rare) +- Mise à jour de V0_702_RELEASE_SCOPE.md si le scope est étendu (exception rare) --- @@ -173,12 +173,12 @@ Pour tout cas ambigu : - v0.602 : Phase 6+ — Payout, Dette Technique & Tests E2E — taguée - v0.603 : Phase 6+ — Transfer automatique Stripe Connect, Commission plateforme, Dette technique — taguée - v0.604 : Phase 6+ — absorbé par v0.701 -- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — en cours +- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — taguée --- ## 8. Rappel pour les contributeurs - **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification. -- **Humains** : Lire [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) avant de coder. +- **Humains** : Lire [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md) avant de coder. - **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder. diff --git a/docs/V0_702_RELEASE_SCOPE.md b/docs/V0_702_RELEASE_SCOPE.md new file mode 100644 index 000000000..12a1ee82c --- /dev/null +++ b/docs/V0_702_RELEASE_SCOPE.md @@ -0,0 +1,18 @@ +# Scope v0.702 — Placeholder + +**Statut** : Placeholder — à définir + +**Version précédente** : [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) (archivé après tag) + +--- + +## Lots prévus (à valider) + +- À définir selon priorisation produit +- Pistes : reviews produits (R1), factures PDF (F1), remboursements (R2) + +--- + +## Référence + +Voir [SCOPE_CONTROL.md](SCOPE_CONTROL.md) pour les règles de scope. diff --git a/docs/V0_604_RELEASE_SCOPE.md b/docs/archive/V0_604_RELEASE_SCOPE.md similarity index 100% rename from docs/V0_604_RELEASE_SCOPE.md rename to docs/archive/V0_604_RELEASE_SCOPE.md diff --git a/docs/V0_701_RELEASE_SCOPE.md b/docs/archive/V0_701_RELEASE_SCOPE.md similarity index 100% rename from docs/V0_701_RELEASE_SCOPE.md rename to docs/archive/V0_701_RELEASE_SCOPE.md diff --git a/veza-backend-api/internal/api/routes_core.go b/veza-backend-api/internal/api/routes_core.go index 4bac75409..6a72f7083 100644 --- a/veza-backend-api/internal/api/routes_core.go +++ b/veza-backend-api/internal/api/routes_core.go @@ -90,6 +90,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { var healthCheckHandler gin.HandlerFunc var livenessHandler gin.HandlerFunc var readinessHandler gin.HandlerFunc + var deepHealthHandler gin.HandlerFunc if r.db != nil && r.db.GormDB != nil { var redisClient interface{} @@ -122,19 +123,35 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { jobWorker, emailSender, ) + if r.config != nil { + healthHandler.SetDeepHealthConfig(&handlers.DeepHealthConfig{ + JWTSecretSet: len(r.config.JWTSecret) >= 32, + StripeConnectEnabled: r.config.StripeConnectEnabled, + PlatformFeeRate: r.config.PlatformFeeRate, + TransferRetryEnabled: r.config.TransferRetryEnabled, + }) + } healthCheckHandler = healthHandler.Check livenessHandler = healthHandler.Liveness readinessHandler = healthHandler.Readiness + deepHealthHandler = healthHandler.DeepHealth } else { healthCheckHandler = handlers.SimpleHealthCheck livenessHandler = handlers.SimpleHealthCheck readinessHandler = handlers.SimpleHealthCheck + deepHealthHandler = func(c *gin.Context) { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": true, + "data": gin.H{"status": "unhealthy", "message": "Database not configured"}, + }) + } } deprecationMW := middleware.DeprecationWarning(r.logger) healthMonitoringMW := middleware.HealthCheckMonitoring(r.logger, r.monitoringService) router.GET("/health", deprecationMW, healthMonitoringMW, healthCheckHandler) + router.GET("/health/deep", deprecationMW, healthMonitoringMW, deepHealthHandler) router.GET("/healthz", deprecationMW, healthMonitoringMW, livenessHandler) router.GET("/readyz", deprecationMW, healthMonitoringMW, readinessHandler) router.GET("/metrics", deprecationMW, handlers.PrometheusMetrics()) @@ -146,6 +163,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { v1Public := router.Group("/api/v1") { v1Public.GET("/health", healthCheckHandler) + v1Public.GET("/health/deep", deepHealthHandler) v1Public.GET("/healthz", livenessHandler) v1Public.GET("/readyz", readinessHandler) diff --git a/veza-backend-api/internal/handlers/health.go b/veza-backend-api/internal/handlers/health.go index 283d88396..a902104e6 100644 --- a/veza-backend-api/internal/handlers/health.go +++ b/veza-backend-api/internal/handlers/health.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "syscall" "time" "github.com/gin-gonic/gin" @@ -14,6 +15,14 @@ import ( "veza-backend-api/internal/eventbus" ) +// DeepHealthConfig holds config summary for /health/deep (v0.701) +type DeepHealthConfig struct { + JWTSecretSet bool + StripeConnectEnabled bool + PlatformFeeRate float64 + TransferRetryEnabled bool +} + // HealthResponse représente la réponse du health check type HealthResponse struct { Status string `json:"status"` @@ -41,6 +50,7 @@ type HealthHandler struct { s3Service interface{} // BE-SVC-016: S3 storage service (optional) jobWorker interface{} // BE-SVC-016: Job worker (optional) emailSender interface{} // BE-SVC-016: Email sender (optional) + deepHealthConfig *DeepHealthConfig // v0.701: config summary for /health/deep } // NewHealthHandler crée un nouveau handler de health @@ -82,6 +92,11 @@ func NewHealthHandlerWithServices( return h } +// SetDeepHealthConfig sets the config summary for DeepHealth (v0.701) +func (h *HealthHandler) SetDeepHealthConfig(cfg *DeepHealthConfig) { + h.deepHealthConfig = cfg +} + // NewHealthHandlerSimple crée un nouveau handler de health simple (sans logger/redis) // Pour compatibilité avec la spécification T0012 func NewHealthHandlerSimple(db *gorm.DB) *HealthHandler { @@ -248,6 +263,96 @@ func (h *HealthHandler) Liveness(c *gin.Context) { }) } +// DeepHealth check endpoint (/health/deep) — v0.701 +// Returns healthy, degraded (non-critical services down), or unhealthy (DB down). +// HTTP: 200 for healthy/degraded, 503 for unhealthy. +func (h *HealthHandler) DeepHealth(c *gin.Context) { + response := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Checks: make(map[string]HealthCheck), + } + + // Critical: DB + dbCheck := h.checkDatabase() + response.Checks["database"] = dbCheck + if dbCheck.Status == "error" { + response.Status = "unhealthy" + response.Message = "Database is down" + c.JSON(http.StatusServiceUnavailable, gin.H{"success": true, "data": response}) + return + } + + // Non-critical: Redis, RabbitMQ, S3, JobWorker, Email + response.Checks["redis"] = h.checkRedis() + response.Checks["rabbitmq"] = h.checkRabbitMQ() + if h.s3Service != nil { + response.Checks["s3_storage"] = h.checkS3() + } + if h.jobWorker != nil { + response.Checks["job_worker"] = h.checkJobWorker() + } + if h.emailSender != nil { + response.Checks["email_sender"] = h.checkEmailSender() + } + + // Disk space + diskCheck := h.checkDiskSpace() + response.Checks["disk"] = diskCheck + + // Config summary (v0.701) + configSummary := make(map[string]interface{}) + if h.deepHealthConfig != nil { + configSummary["jwt_secret_set"] = h.deepHealthConfig.JWTSecretSet + configSummary["stripe_connect_enabled"] = h.deepHealthConfig.StripeConnectEnabled + configSummary["platform_fee_rate"] = h.deepHealthConfig.PlatformFeeRate + configSummary["transfer_retry_enabled"] = h.deepHealthConfig.TransferRetryEnabled + } + + // Determine overall status + for key, check := range response.Checks { + if key == "database" { + continue + } + if check.Status == "error" { + response.Status = "degraded" + break + } + } + + // Build response with config field + resp := gin.H{ + "status": response.Status, + "timestamp": response.Timestamp, + "checks": response.Checks, + "config": configSummary, + } + if response.Message != "" { + resp["message"] = response.Message + } + RespondSuccess(c, http.StatusOK, resp) +} + +// checkDiskSpace returns free disk space in GB (v0.701) +func (h *HealthHandler) checkDiskSpace() HealthCheck { + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err != nil { + return HealthCheck{ + Status: "error", + Message: err.Error(), + } + } + // Block size * free blocks / (1024^3) = GB + freeGB := float64(stat.Bfree) * float64(stat.Bsize) / (1024 * 1024 * 1024) + return HealthCheck{ + Status: "ok", + Message: "disk space available", + Details: map[string]interface{}{ + "free_gb": freeGB, + }, + } +} + // SimpleHealthCheck est une fonction simple pour le health check endpoint public func SimpleHealthCheck(c *gin.Context) { RespondSuccess(c, http.StatusOK, gin.H{ diff --git a/veza-backend-api/internal/handlers/health_deep_test.go b/veza-backend-api/internal/handlers/health_deep_test.go new file mode 100644 index 000000000..f12b96c49 --- /dev/null +++ b/veza-backend-api/internal/handlers/health_deep_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestDeepHealth_AllHealthy(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + logger := zaptest.NewLogger(t) + handler := NewHealthHandler(db, logger, nil, nil, "test") + handler.SetDeepHealthConfig(&DeepHealthConfig{ + JWTSecretSet: true, + StripeConnectEnabled: false, + PlatformFeeRate: 0.10, + TransferRetryEnabled: true, + }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/health/deep", handler.DeepHealth) + + req, _ := http.NewRequest("GET", "/health/deep", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var wrapper map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper)) + data, ok := wrapper["data"].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, []string{"healthy", "degraded"}, data["status"]) + assert.Contains(t, data["checks"], "database") + assert.Contains(t, data["checks"], "disk") + assert.Contains(t, data, "config") +} + +func TestDeepHealth_DBDown(t *testing.T) { + logger := zaptest.NewLogger(t) + handler := NewHealthHandler(nil, logger, nil, nil, "test") + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/health/deep", handler.DeepHealth) + + req, _ := http.NewRequest("GET", "/health/deep", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var wrapper map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper)) + data, ok := wrapper["data"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "unhealthy", data["status"]) +} + +func TestDeepHealth_RedisDegraded(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + logger := zaptest.NewLogger(t) + // Redis nil = "error" from checkRedis + handler := NewHealthHandler(db, logger, nil, nil, "test") + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/health/deep", handler.DeepHealth) + + req, _ := http.NewRequest("GET", "/health/deep", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Degraded = 200 OK (load balancer continues) + assert.Equal(t, http.StatusOK, w.Code) + + var wrapper map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper)) + data, ok := wrapper["data"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "degraded", data["status"]) +} + +func TestDeepHealth_ConfigSummary(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + logger := zaptest.NewLogger(t) + handler := NewHealthHandler(db, logger, nil, nil, "test") + handler.SetDeepHealthConfig(&DeepHealthConfig{ + JWTSecretSet: true, + StripeConnectEnabled: true, + PlatformFeeRate: 0.15, + TransferRetryEnabled: false, + }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/health/deep", handler.DeepHealth) + + req, _ := http.NewRequest("GET", "/health/deep", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var wrapper map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper)) + data, ok := wrapper["data"].(map[string]interface{}) + require.True(t, ok) + + config, ok := data["config"].(map[string]interface{}) + require.True(t, ok, "Response should have 'config' field") + assert.Equal(t, true, config["jwt_secret_set"]) + assert.Equal(t, true, config["stripe_connect_enabled"]) + assert.Equal(t, 0.15, config["platform_fee_rate"]) + assert.Equal(t, false, config["transfer_retry_enabled"]) +}