From d0e0c35f112d49bc735c5faaedf5b5d2cf8f3f4d Mon Sep 17 00:00:00 2001 From: samuel1-ona Date: Thu, 28 May 2026 16:17:46 +0100 Subject: [PATCH] fix: typed error taxonomy, Redis degraded mode, and analytics UX (#607 #615 #632 #637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #632 – typed error taxonomy: - Add ExternalDependencyError (HTTP 502) to backend/src/errors/index.ts - Add backend/tests/error-taxonomy.test.ts asserting HTTP response shapes for every domain error class Issue #637 – harden Redis outage behavior: - Add isDegraded() to RateLimitRedisStore; exposed in getHealthStatus() - Extend RateLimiterFactory.getStoreStatus() with a degraded flag so health endpoints can surface memory-fallback mode - Add outage scenario tests in redis-store.test.ts (connect/disconnect transitions, fallback via createRedisStore) Issue #607 – dashboard loading/empty/error states: - Add client/app/dashboard/analytics/loading.tsx (skeleton layout matching page structure) - Add client/app/dashboard/analytics/error.tsx (Next.js error boundary with retry button) - Add empty state to analytics route when active_subscriptions === 0 - Error state in analytics route now has a retry button (calls fetchAnalytics again) Issue #615 – responsive analytics pages: - Stats grid: 1 col on mobile, 3 col on sm+ - Charts: wrapped in overflow-x-auto with min-width guard; reduced height on mobile - Category legend: 1 col mobile, 2 col sm+; labels truncated to prevent overflow - Top subscriptions: horizontal-scroll wrapper, name truncation, flex-shrink on price column - Budget row: flex-wrap so label and amount stack on narrow screens Co-Authored-By: Claude Sonnet 4.6 --- backend/src/errors/index.ts | 16 ++ backend/src/lib/redis-store.ts | 11 +- backend/src/middleware/rate-limit-factory.ts | 8 +- backend/tests/error-taxonomy.test.ts | 156 +++++++++++++++++++ backend/tests/redis-store.test.ts | 89 +++++++++++ client/app/dashboard/analytics/error.tsx | 33 ++++ client/app/dashboard/analytics/loading.tsx | 21 +++ client/app/dashboard/analytics/page.tsx | 82 ++++++---- client/components/pages/analytics.tsx | 118 +++++++------- 9 files changed, 449 insertions(+), 85 deletions(-) create mode 100644 backend/tests/error-taxonomy.test.ts create mode 100644 client/app/dashboard/analytics/error.tsx create mode 100644 client/app/dashboard/analytics/loading.tsx diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index 9afb7cf0..290f2cf1 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -78,3 +78,19 @@ export class RateLimitError extends AppError { super('Too Many Requests', 429, detail, 'https://syncro.app/errors/too-many-requests', { retryAfter }); } } + +/** + * Thrown when an external dependency (Redis, email provider, payment gateway, etc.) + * is unavailable or returns an unexpected error (HTTP 502). + */ +export class ExternalDependencyError extends AppError { + constructor(detail: string, public dependency: string) { + super( + 'External Dependency Error', + 502, + detail, + 'https://syncro.app/errors/external-dependency', + { dependency } + ); + } +} diff --git a/backend/src/lib/redis-store.ts b/backend/src/lib/redis-store.ts index 1d23b96e..8b7c5b93 100644 --- a/backend/src/lib/redis-store.ts +++ b/backend/src/lib/redis-store.ts @@ -105,12 +105,21 @@ export class RateLimitRedisStore { return this.isConnected && this.store !== null; } + /** + * Returns true when Redis was configured but is currently unavailable, + * meaning the system is operating in degraded mode with a memory fallback. + */ + isDegraded(): boolean { + return !!(rateLimitConfig.redis.enabled && rateLimitConfig.redis.url && !this.isConnected); + } + /** * Get Redis connection health status */ - getHealthStatus(): { connected: boolean; reconnectAttempts: number; error?: string } { + getHealthStatus(): { connected: boolean; degraded: boolean; reconnectAttempts: number; error?: string } { return { connected: this.isConnected, + degraded: this.isDegraded(), reconnectAttempts: this.reconnectAttempts, error: this.isConnected ? undefined : 'Redis connection unavailable', }; diff --git a/backend/src/middleware/rate-limit-factory.ts b/backend/src/middleware/rate-limit-factory.ts index 50fad311..e2e1fb43 100644 --- a/backend/src/middleware/rate-limit-factory.ts +++ b/backend/src/middleware/rate-limit-factory.ts @@ -202,12 +202,16 @@ export class RateLimiterFactory { } /** - * Get Redis store status for health monitoring + * Get Redis store status for health monitoring. + * When `degraded` is true the app is running with the in-memory fallback + * because Redis was configured but is currently unreachable. */ - static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean } { + static getStoreStatus(): { type: 'redis' | 'memory'; available: boolean; degraded: boolean } { + const degraded = this.redisStoreInitialized && !this.redisStore; return { type: this.redisStore ? 'redis' : 'memory', available: this.redisStoreInitialized, + degraded, }; } } diff --git a/backend/tests/error-taxonomy.test.ts b/backend/tests/error-taxonomy.test.ts new file mode 100644 index 00000000..dfbb5c83 --- /dev/null +++ b/backend/tests/error-taxonomy.test.ts @@ -0,0 +1,156 @@ +import { createServer } from 'http'; +import express, { Request, Response } from 'express'; +import request from 'supertest'; +import { + AppError, + NotFoundError, + ValidationError, + UnauthorizedError, + ForbiddenError, + ConflictError, + BadRequestError, + RateLimitError, + ExternalDependencyError, +} from '../src/errors'; +import { errorHandler } from '../src/middleware/errorHandler'; + +function buildApp(thrower: (req: Request, res: Response, next: any) => void) { + const app = express(); + app.use(express.json()); + app.get('/test', thrower); + app.use(errorHandler); + return app; +} + +describe('error taxonomy – class shapes', () => { + it('NotFoundError has correct status and type', () => { + const err = new NotFoundError('Widget not found'); + expect(err.status).toBe(404); + expect(err.title).toBe('Not Found'); + expect(err.type).toBe('https://syncro.app/errors/not-found'); + expect(err.detail).toBe('Widget not found'); + expect(err).toBeInstanceOf(AppError); + }); + + it('ValidationError carries field errors', () => { + const err = new ValidationError('Invalid input', { email: ['must be valid'] }); + expect(err.status).toBe(400); + expect(err.errors).toEqual({ email: ['must be valid'] }); + }); + + it('UnauthorizedError defaults message', () => { + const err = new UnauthorizedError(); + expect(err.status).toBe(401); + expect(err.detail).toBe('Authentication required.'); + }); + + it('ForbiddenError defaults message', () => { + const err = new ForbiddenError(); + expect(err.status).toBe(403); + expect(err.detail).toBe('Access denied.'); + }); + + it('ConflictError has 409 status', () => { + const err = new ConflictError('Resource already exists'); + expect(err.status).toBe(409); + expect(err.type).toBe('https://syncro.app/errors/conflict'); + }); + + it('RateLimitError carries retryAfter', () => { + const err = new RateLimitError('Slow down', 60); + expect(err.status).toBe(429); + expect(err.retryAfter).toBe(60); + expect(err.extensions).toMatchObject({ retryAfter: 60 }); + }); + + it('ExternalDependencyError has 502 status and dependency field', () => { + const err = new ExternalDependencyError('Stripe is unreachable', 'stripe'); + expect(err.status).toBe(502); + expect(err.dependency).toBe('stripe'); + expect(err.type).toBe('https://syncro.app/errors/external-dependency'); + expect(err).toBeInstanceOf(AppError); + }); +}); + +describe('error taxonomy – HTTP response shapes via errorHandler', () => { + it('NotFoundError → 404 Problem Details', async () => { + const app = buildApp((_req, _res, next) => next(new NotFoundError('Widget not found'))); + const res = await request(app).get('/test'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toMatch(/application\/problem\+json/); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/not-found', + title: 'Not Found', + status: 404, + detail: 'Widget not found', + }); + }); + + it('ValidationError → 400 with errors array', async () => { + const app = buildApp((_req, _res, next) => + next(new ValidationError('Bad input', { name: ['required'] })) + ); + const res = await request(app).get('/test'); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/validation', + status: 400, + errors: { name: ['required'] }, + }); + }); + + it('UnauthorizedError → 401', async () => { + const app = buildApp((_req, _res, next) => next(new UnauthorizedError())); + const res = await request(app).get('/test'); + expect(res.status).toBe(401); + expect(res.body.status).toBe(401); + }); + + it('ForbiddenError → 403', async () => { + const app = buildApp((_req, _res, next) => next(new ForbiddenError())); + const res = await request(app).get('/test'); + expect(res.status).toBe(403); + expect(res.body.status).toBe(403); + }); + + it('ConflictError → 409', async () => { + const app = buildApp((_req, _res, next) => next(new ConflictError('Duplicate key'))); + const res = await request(app).get('/test'); + expect(res.status).toBe(409); + expect(res.body.status).toBe(409); + }); + + it('RateLimitError → 429 with retryAfter in body', async () => { + const app = buildApp((_req, _res, next) => next(new RateLimitError('Too fast', 30))); + const res = await request(app).get('/test'); + expect(res.status).toBe(429); + expect(res.body).toMatchObject({ status: 429, retryAfter: 30 }); + }); + + it('ExternalDependencyError → 502 with dependency in body', async () => { + const app = buildApp((_req, _res, next) => + next(new ExternalDependencyError('Redis unavailable', 'redis')) + ); + const res = await request(app).get('/test'); + expect(res.status).toBe(502); + expect(res.body).toMatchObject({ + type: 'https://syncro.app/errors/external-dependency', + status: 502, + dependency: 'redis', + }); + }); + + it('unknown error → 500 without leaking internals in production', async () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const app = buildApp((_req, _res, next) => next(new Error('secret internal error'))); + const res = await request(app).get('/test'); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe('An unexpected error occurred.'); + expect(res.body.detail).not.toContain('secret'); + + process.env.NODE_ENV = original; + }); +}); diff --git a/backend/tests/redis-store.test.ts b/backend/tests/redis-store.test.ts index c1e6b49a..5dbfa748 100644 --- a/backend/tests/redis-store.test.ts +++ b/backend/tests/redis-store.test.ts @@ -138,6 +138,7 @@ describe('RateLimitRedisStore', () => { expect(status).toEqual({ connected: false, + degraded: true, reconnectAttempts: 0, error: 'Redis connection unavailable', }); @@ -155,6 +156,7 @@ describe('RateLimitRedisStore', () => { expect(status).toEqual({ connected: true, + degraded: false, reconnectAttempts: 0, error: undefined, }); @@ -187,6 +189,93 @@ describe('RateLimitRedisStore', () => { expect(reconnectStrategy(4)).toBe(30000); // Capped at 30 seconds }); }); + + describe('outage scenarios', () => { + it('isDegraded returns false when redis is not configured', () => { + const { rateLimitConfig } = require('../src/config/rate-limit'); + const savedEnabled = rateLimitConfig.redis.enabled; + rateLimitConfig.redis.enabled = false; + + const store = RateLimitRedisStore.getInstance(); + expect(store.isDegraded()).toBe(false); + + rateLimitConfig.redis.enabled = savedEnabled; + }); + + it('isDegraded returns true when redis is configured but disconnected', () => { + const store = RateLimitRedisStore.getInstance(); + // Store is configured (mocked config has enabled: true) but not yet connected + expect(store.isDegraded()).toBe(true); + }); + + it('isDegraded returns false after successful connection', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + // Simulate connect event + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + + expect(store.isDegraded()).toBe(false); + }); + + it('falls back to memory store and is marked degraded on connection failure', async () => { + mockRedisClient.connect.mockRejectedValue(new Error('ECONNREFUSED')); + const store = RateLimitRedisStore.getInstance(); + + await expect(store.initialize()).rejects.toThrow('ECONNREFUSED'); + + expect(store.isAvailable()).toBe(false); + expect(store.getStore()).toBeNull(); + }); + + it('falls back silently via createRedisStore when redis is unreachable', async () => { + const mockStoreInstance = { + initialize: jest.fn().mockRejectedValue(new Error('timeout')), + getStore: jest.fn().mockReturnValue(null), + }; + jest.spyOn(RateLimitRedisStore, 'getInstance').mockReturnValue(mockStoreInstance as any); + + const result = await createRedisStore(); + + expect(result).toBeUndefined(); + }); + + it('getHealthStatus includes degraded flag', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + const status = store.getHealthStatus(); + expect(status).toHaveProperty('degraded'); + expect(typeof status.degraded).toBe('boolean'); + }); + + it('transitions from degraded to healthy on reconnect event', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + expect(store.isDegraded()).toBe(true); + + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + + expect(store.isDegraded()).toBe(false); + expect(store.getHealthStatus().degraded).toBe(false); + }); + + it('transitions back to degraded after disconnect event', async () => { + const store = RateLimitRedisStore.getInstance(); + await store.initialize(); + + const connectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'connect')[1]; + connectHandler(); + expect(store.isDegraded()).toBe(false); + + const disconnectHandler = mockRedisClient.on.mock.calls.find((c: any[]) => c[0] === 'disconnect')[1]; + disconnectHandler(); + expect(store.isDegraded()).toBe(true); + }); + }); }); describe('createRedisStore', () => { diff --git a/client/app/dashboard/analytics/error.tsx b/client/app/dashboard/analytics/error.tsx new file mode 100644 index 00000000..c2cadce8 --- /dev/null +++ b/client/app/dashboard/analytics/error.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect } from "react" +import { AlertTriangle } from "lucide-react" + +interface AnalyticsErrorProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function AnalyticsError({ error, reset }: AnalyticsErrorProps) { + useEffect(() => { + console.error("Analytics route error:", error) + }, [error]) + + return ( +
+
+ ) +} diff --git a/client/app/dashboard/analytics/loading.tsx b/client/app/dashboard/analytics/loading.tsx new file mode 100644 index 00000000..92f16125 --- /dev/null +++ b/client/app/dashboard/analytics/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function AnalyticsLoading() { + return ( +
+ {/* Stats row */} +
+ + + +
+ {/* Charts row */} +
+ + +
+ {/* Top subscriptions */} + +
+ ) +} diff --git a/client/app/dashboard/analytics/page.tsx b/client/app/dashboard/analytics/page.tsx index 0e84a993..490e0ae5 100644 --- a/client/app/dashboard/analytics/page.tsx +++ b/client/app/dashboard/analytics/page.tsx @@ -5,6 +5,7 @@ import AnalyticsPage from "@/components/pages/analytics" import { analyticsApi, AnalyticsSummary } from "@/lib/api/analytics" import { useTheme } from "next-themes" import { Skeleton } from "@/components/ui/skeleton" +import { BarChart3, AlertTriangle } from "lucide-react" export default function AnalyticsRoute() { const [summary, setSummary] = useState(null) @@ -13,52 +14,79 @@ export default function AnalyticsRoute() { const { theme } = useTheme() const darkMode = theme === "dark" - useEffect(() => { - const fetchAnalytics = async () => { - try { - const data = await analyticsApi.getSummary() - setSummary(data) - } catch (err) { - console.error("Failed to fetch analytics:", err) - setError("Failed to load analytics data. Please try again later.") - } finally { - setLoading(false) - } + const fetchAnalytics = async () => { + setError(null) + setLoading(true) + try { + const data = await analyticsApi.getSummary() + setSummary(data) + } catch (err) { + console.error("Failed to fetch analytics:", err) + setError("Failed to load analytics data. Please try again later.") + } finally { + setLoading(false) } + } + useEffect(() => { fetchAnalytics() }, []) if (loading) { return ( -
-
- - - +
+
+ + +
- -
- - +
+ +
+ +
+ ) + } + + if (error) { + return ( +
+
) } - if (error || !summary) { + if (!summary || summary.active_subscriptions === 0) { return ( -
-

{error || "Something went wrong"}

+
+
) } return ( -
-
-

Spending Analytics

-

Track your subscription spend and stay within budget.

+
+
+

+ Spending Analytics +

+

+ Track your subscription spend and stay within budget. +

diff --git a/client/components/pages/analytics.tsx b/client/components/pages/analytics.tsx index 07690ad4..dac42be7 100644 --- a/client/components/pages/analytics.tsx +++ b/client/components/pages/analytics.tsx @@ -54,24 +54,24 @@ export default function AnalyticsPage({ summary, darkMode, savedBySyncroCount = } return ( -
+
{/* Stats Overview */} -
-
-

Total Monthly Spend

-

+

+
+

Total Monthly Spend

+

{formatCurrency(summary.total_monthly_spend, currency)}

-
-

Active Subscriptions

-

+

+

Active Subscriptions

+

{summary.active_subscriptions}

-
-

Upcoming (7 days)

-

+

+

Upcoming (7 days)

+

{summary.upcoming_renewals_count}

@@ -79,10 +79,10 @@ export default function AnalyticsPage({ summary, darkMode, savedBySyncroCount = {/* Budget Progress */} {summary.budget_status.overall_limit && ( -
-
+
+

Monthly Budget

- + {formatCurrency(summary.budget_status.current_spend, currency)} / {formatCurrency(summary.budget_status.overall_limit, currency)}
@@ -94,27 +94,31 @@ export default function AnalyticsPage({ summary, darkMode, savedBySyncroCount = )} {/* Main Charts */} -
-
-

Spending Trend

- - - - - - formatCurrency(Number(value), currency)} - contentStyle={{ backgroundColor: darkMode ? "#1F2937" : "#FFF", border: "none", borderRadius: "8px" }} - itemStyle={{ color: "#6366F1" }} - /> - - - +
+
+

Spending Trend

+
+
+ + + + + + formatCurrency(Number(value), currency)} + contentStyle={{ backgroundColor: darkMode ? "#1F2937" : "#FFF", border: "none", borderRadius: "8px" }} + itemStyle={{ color: "#6366F1" }} + /> + + + +
+
-
-

Category Breakdown

- +
+

Category Breakdown

+ {summary.category_breakdown.map((entry, index) => ( @@ -133,11 +137,11 @@ export default function AnalyticsPage({ summary, darkMode, savedBySyncroCount = formatCurrency(Number(value), currency)} /> -
+
{summary.category_breakdown.map((cat, idx) => ( -
-
- {cat.category}: {formatCurrency(cat.total_spend, currency)} +
+
+ {cat.category}: {formatCurrency(cat.total_spend, currency)}
))}
@@ -160,23 +164,27 @@ export default function AnalyticsPage({ summary, darkMode, savedBySyncroCount = )} {/* Top Subscriptions */} -
-

Top Subscriptions (Monthly)

-
- {summary.top_subscriptions.map((sub, idx) => ( -
-
-

{sub.name}

-

{sub.billing_cycle}

-
-
-

- {formatCurrency(sub.monthly_normalized_price, currency)} -

-

Active

-
+
+

Top Subscriptions (Monthly)

+
+
+
+ {summary.top_subscriptions.map((sub, idx) => ( +
+
+

{sub.name}

+

{sub.billing_cycle}

+
+
+

+ {formatCurrency(sub.monthly_normalized_price, currency)} +

+

Active

+
+
+ ))}
- ))} +