Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {}
body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }),
}));

// A restored session is confirmed with GET /user. A 401 or 403 signs the tab
// out, so the stub user has to answer that call or the page returns to /login.
await page.route((url) => url.pathname.endsWith('/auth/v1/user'), (route) => {
if (route.request().method() !== 'GET') return route.fallback();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(session.user),
});
});

// Credential-free render checks must not call the production announcement
// function. Tests that exercise announcements register a more specific
// route after this default.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",
Expand Down
5 changes: 3 additions & 2 deletions src/components/WebTelemetry.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { loadOptionalComponent } from "@/components/optional-telemetry";

const Analytics = lazy(() => import("@vercel/analytics/react").then(({ Analytics: Component }) => ({ default: Component })));
const SpeedInsights = lazy(() => import("@vercel/speed-insights/react").then(({ SpeedInsights: Component }) => ({ default: Component })));
const Analytics = lazy(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics"));
const SpeedInsights = lazy(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights"));

export function WebTelemetry() {
const [ready, setReady] = useState(false);
Expand Down
28 changes: 28 additions & 0 deletions src/components/optional-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ComponentType } from "react";

function HiddenTelemetry(): null {
return null;
}

/**
* Analytics chunks are optional. Vite resolves a prevented preload error to
* undefined, and destructuring that argument throws
* "(destructured parameter) is undefined" in Firefox, which takes down /login.
*/
export function componentFromModule(
module: Record<string, unknown> | null | undefined,
exportName: string,
): ComponentType {
const component = module?.[exportName];
return typeof component === "function" ? component as ComponentType : HiddenTelemetry;
}

export function loadOptionalComponent(
load: () => Promise<Record<string, unknown> | null | undefined>,
exportName: string,
): Promise<{ default: ComponentType }> {
return Promise.resolve()
.then(load)
.then((module) => ({ default: componentFromModule(module, exportName) }))
.catch(() => ({ default: HiddenTelemetry }));
}
83 changes: 83 additions & 0 deletions src/hooks/use-subscription.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const orders: { table: string; column: string }[] = [];
let rateLimitResult: { data: { limit_value: number } | null; error: { message: string } | null } = {
data: { limit_value: 12 },
error: null,
};

function builder(table: string) {
let column = '';
const api = {
select: () => api,
eq: () => api,
gte: () => api,
neq: () => api,
not: () => api,
order: (next: string) => {
column = next;
orders.push({ table, column: next });
return api;
},
limit: () => api,
maybeSingle: () => Promise.resolve(table === 'user_settings'
? { data: { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }, error: null }
: table === 'rate_limits' ? rateLimitResult : { data: null, error: null }),
then: (resolve: (value: unknown) => void, reject?: (reason: unknown) => void) => Promise.resolve(
table === 'subscriptions'
? { data: [], error: null }
: table === 'ai_requests'
? { count: 1, error: null }
: { data: null, error: null },
).then(resolve, reject),
};
return api;
}

vi.mock('@/lib/supabase/AuthProvider', () => ({
useAuth: () => ({ user: { id: 'user-1' } }),
}));

vi.mock('@/lib/supabase/client', () => ({
supabase: { from: (table: string) => builder(table) },
}));

import { useSubscription } from './use-subscription';

function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

describe('useSubscription rate limits', () => {
beforeEach(() => {
orders.length = 0;
rateLimitResult = { data: { limit_value: 12 }, error: null };
});

it('reads the account limit ordered by created_at and uses it', async () => {
const { result } = renderHook(() => useSubscription(), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));

const rateOrders = orders.filter((order) => order.table === 'rate_limits');
expect(rateOrders.length).toBeGreaterThan(0);
expect(rateOrders.every((order) => order.column === 'created_at')).toBe(true);
expect(orders.some((order) => order.column === 'updated_at')).toBe(false);
expect(result.current.dailyLimit).toBe(12);
expect(result.current.remainingToday).toBe(11);
});

it('logs a lookup error and leaves the remaining count unknown', async () => {
rateLimitResult = { data: null, error: { message: 'column rate_limits.updated_at does not exist' } };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => useSubscription(), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));

expect(warn).toHaveBeenCalledWith('[useSubscription] rate_limits lookup failed:', 'column rate_limits.updated_at does not exist');
expect(result.current.remainingToday).toBeNull();
warn.mockRestore();
});
});
7 changes: 5 additions & 2 deletions src/hooks/use-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,13 @@ async function fetchSubscription(userId: string): Promise<SubscriptionSnapshot>
.select('limit_value')
.eq('user_id', userId)
.eq('limit_type', 'daily_requests')
.order('updated_at', { ascending: false })
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (limitError) limitKnown = false;
if (limitError) {
console.warn('[useSubscription] rate_limits lookup failed:', limitError.message);
limitKnown = false;
}
else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value;
}

Expand Down
8 changes: 8 additions & 0 deletions src/lib/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ export {
AIProviderError,
AIResponseError,
TimeoutError,
AINetworkError,
AIServerError,
AIUnknownError,
isAIAuthError,
isAINetworkError,
isAIRateLimitError,
isAIProviderError,
isTimeoutError,
isAISubscriptionError,
isAIModelRestrictionError,
classifyAIError,
mapAIError,
isBrowserNetworkFailure,
browserIsOffline,
privacySafeCauseType,
offlineAIMessage,
} from './errors';
export type { AIErrorClassification } from './errors';
export {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/ai/error-display.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import i18n from '@/i18n';
import { AIProviderError, isAISubscriptionError } from './client';
import { AIProviderError, isAINetworkError, isAISubscriptionError } from './errors';
import { subscriptionLimitMessage } from './limit-message';

const PROVIDER_LABELS: Record<string, string> = {
Expand All @@ -21,6 +21,7 @@ function providerLabel(provider?: string, message = ''): string {
export { subscriptionLimitMessage };

export function getUserFacingAIError(error: unknown): string {
if (isAINetworkError(error)) return i18n.t('aiErrors.offline');
if (isAISubscriptionError(error)) return subscriptionLimitMessage(error);

if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {
Expand Down
131 changes: 131 additions & 0 deletions src/lib/ai/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
AIAuthError,
AIModelRestrictionError,
AINetworkError,
AISubscriptionError,
AIProviderError,
AIServerError,
AIUnknownError,
TimeoutError,
classifyAIError,
mapAIError,
} from './errors';

describe('classifyAIError', () => {
it('treats a fetch TypeError as a network failure and does not capture it', () => {
const classification = classifyAIError(new TypeError('Failed to fetch'));
expect(classification).toMatchObject({ errorName: 'AINetworkError', shouldCaptureException: false });
});

it('captures an unknown error under its own name with a privacy-safe cause type', () => {
const classification = classifyAIError(new Error('student motion and private notes'));
expect(classification).toMatchObject({
errorName: 'AIUnknownError',
shouldCaptureException: true,
causeType: 'Error:Error',
});
expect(classification.causeType).not.toContain('student');
expect(JSON.stringify(classification)).not.toContain('private notes');
});

it('keeps subscription, model and timeout failures on their existing names', () => {
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).shouldCaptureException).toBe(false);
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError');
expect(classifyAIError(new AIModelRestrictionError('upgrade')).errorName).toBe('AIModelRestrictionError');
expect(classifyAIError(new AIAuthError('no', { statusCode: 403 })).shouldCaptureException).toBe(false);
expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true });
});

it('recognizes the other browser network messages and an offline navigator', () => {
expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError');
expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError');
expect(classifyAIError(new TypeError('Network error')).errorName).toBe('AINetworkError');
const online = navigator.onLine;
Object.defineProperty(navigator, 'onLine', { configurable: true, value: false });
try {
expect(classifyAIError(new Error('socket closed')).errorName).toBe('AINetworkError');
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError');
} finally {
Object.defineProperty(navigator, 'onLine', { configurable: true, value: online });
}
});
});

describe('mapAIError', () => {
it('maps a wrapped fetch failure to a network error without a status code', () => {
const error = mapAIError(
{ message: 'Failed to send a request to the Edge Function', context: new TypeError('Failed to fetch') },
'req-1',
'openrouter',
'm',
);
expect(error).toBeInstanceOf(AINetworkError);
expect(error.statusCode).toBeUndefined();
expect(classifyAIError(error).shouldCaptureException).toBe(false);
});

it('maps an unknown transport error without putting its message in the cause type', () => {
const error = mapAIError({ message: 'student motion leaked' }, 'req-1', 'openrouter', 'm');
expect(error).toBeInstanceOf(AIUnknownError);
expect((error as AIUnknownError).causeType).toBe('Unknown:Unknown');
expect((error as AIUnknownError).causeType).not.toContain('student');
expect(classifyAIError(error).shouldCaptureException).toBe(true);
});

it('still maps 402, 403 and abort messages as before', () => {
const limited = mapAIError(
{ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } },
'req-1',
'openrouter',
'm',
{ error: 'limit', code: 'limit_reached' },
);
expect(limited).toBeInstanceOf(AISubscriptionError);
expect(limited.statusCode).toBe(402);

const upgrade = mapAIError(
{ message: 'Edge Function returned a non-2xx status code', context: { status: 403 } },
'req-1',
'openrouter',
'm',
{ code: 'upgrade_required', error: 'This model requires Pro' },
);
expect(upgrade).toBeInstanceOf(AIModelRestrictionError);

const forbidden = mapAIError(
{ message: 'Edge Function returned a non-2xx status code', context: { status: 403 } },
'req-1',
'openrouter',
'm',
{ error: 'Access denied' },
);
expect(forbidden).toBeInstanceOf(AIAuthError);

expect(mapAIError({ message: 'The operation was aborted' }, 'req-1', 'openrouter', 'm')).toBeInstanceOf(TimeoutError);
});

it('keeps a provider_error 500 on AIProviderError and other 500s on AIServerError', () => {
const provider = mapAIError(
{ message: 'Edge Function returned a non-2xx status code', context: { status: 500 } },
'req-1',
'openrouter',
'm',
{ error: 'AI request failed. Please try again.', code: 'provider_error' },
);
expect(provider).toBeInstanceOf(AIProviderError);
expect(provider.statusCode).toBe(500);
expect(classifyAIError(provider).errorName).toBe('AIProviderError');

const server = mapAIError(
{ message: 'Edge Function returned a non-2xx status code', context: { status: 500 } },
'req-1',
'openrouter',
'm',
{ error: 'Unable to verify your quota. Please try again.' },
);
expect(server).toBeInstanceOf(AIServerError);
expect(server.statusCode).toBe(500);
expect(classifyAIError(server)).toMatchObject({ errorName: 'AIServerError', statusCode: 500, shouldCaptureException: true });
});
});
Loading
Loading