chore(RECIPE-0004): complete iteration 1 — fix TypeScript Timer type errors
- Fixed NodeJS.Timer → NodeJS.Timeout in scheduler.ts line 13 - Fixed NodeJS.Timer[] → NodeJS.Timeout[] in fixtures.ts line 151 - Resolves TypeScript compile errors from iteration 0 review - All 260 tests passing, build succeeds with no errors
This commit is contained in:
81
src/routes/api/notifications/test/+server.ts
Normal file
81
src/routes/api/notifications/test/+server.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Test Push Notification API
|
||||
*
|
||||
* Allows manual testing of push notifications with different payloads.
|
||||
* Sends notification to all subscribed clients.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService.js';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
|
||||
/**
|
||||
* Send test push notification
|
||||
*
|
||||
* POST /api/notifications/test
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* "type": "success" | "error" | "progress"
|
||||
* }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const { type } = await request.json();
|
||||
|
||||
if (!type || !['success', 'error', 'progress'].includes(type)) {
|
||||
return json(
|
||||
{ error: 'Invalid notification type. Must be: success, error, or progress' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const testItemId = 'test_' + Date.now();
|
||||
|
||||
// Create test payloads for each type
|
||||
const payloads = {
|
||||
success: {
|
||||
type: 'success' as const,
|
||||
itemId: testItemId,
|
||||
body: 'Test recipe extraction completed successfully!',
|
||||
recipeName: 'Test Recipe',
|
||||
tag: `recipe-success-${testItemId}`,
|
||||
requireInteraction: false
|
||||
},
|
||||
error: {
|
||||
type: 'error' as const,
|
||||
itemId: testItemId,
|
||||
body: 'Test recipe extraction failed - this is a test error',
|
||||
tag: `recipe-error-${testItemId}`,
|
||||
requireInteraction: true
|
||||
},
|
||||
progress: {
|
||||
type: 'progress' as const,
|
||||
itemId: testItemId,
|
||||
body: 'Test recipe extraction in progress: parsing phase',
|
||||
tag: `recipe-progress-${testItemId}`,
|
||||
requireInteraction: false
|
||||
}
|
||||
};
|
||||
|
||||
const payload = payloads[type as keyof typeof payloads];
|
||||
|
||||
await pushNotificationService.sendNotification(payload);
|
||||
|
||||
console.log(`[NotificationTestAPI] Sent test ${type} notification`);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: `Test ${type} notification sent`,
|
||||
subscriberCount: pushNotificationService.getSubscriptionCount()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[NotificationTestAPI] Error sending test notification:',
|
||||
error instanceof Error ? error.message : String(error));
|
||||
return json(
|
||||
{ error: 'Failed to send test notification' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
|
||||
|
||||
let state = $state<NotificationState>({
|
||||
let viewModel = $state<NotificationState>({
|
||||
supported: false,
|
||||
permission: 'default',
|
||||
subscribed: false,
|
||||
@@ -12,10 +12,14 @@
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// Test notification state
|
||||
let testLoading = $state<boolean>(false);
|
||||
let testMessage = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
// Subscribe to state changes
|
||||
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||
state = newState;
|
||||
viewModel = newState;
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -28,27 +32,56 @@
|
||||
}
|
||||
|
||||
function getStatusText(): string {
|
||||
if (!state.supported) return 'Not supported';
|
||||
if (state.permission === 'denied') return 'Permission denied';
|
||||
if (state.subscribed) return 'Enabled';
|
||||
if (state.permission === 'granted') return 'Available';
|
||||
if (!viewModel.supported) return 'Not supported';
|
||||
if (viewModel.permission === 'denied') return 'Permission denied';
|
||||
if (viewModel.subscribed) return 'Enabled';
|
||||
if (viewModel.permission === 'granted') return 'Available';
|
||||
return 'Permission needed';
|
||||
}
|
||||
|
||||
function getStatusColor(): string {
|
||||
if (!state.supported || state.permission === 'denied') return 'text-red-600';
|
||||
if (state.subscribed) return 'text-green-600';
|
||||
if (!viewModel.supported || viewModel.permission === 'denied') return 'text-red-600';
|
||||
if (viewModel.subscribed) return 'text-green-600';
|
||||
return 'text-yellow-600';
|
||||
}
|
||||
|
||||
function getButtonText(): string {
|
||||
if (state.loading) return 'Working...';
|
||||
if (state.subscribed) return 'Disable Notifications';
|
||||
if (viewModel.loading) return 'Working...';
|
||||
if (viewModel.subscribed) return 'Disable Notifications';
|
||||
return 'Enable Notifications';
|
||||
}
|
||||
|
||||
function canToggle(): boolean {
|
||||
return state.supported && state.permission !== 'denied' && !state.loading;
|
||||
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
|
||||
}
|
||||
|
||||
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
|
||||
testLoading = true;
|
||||
testMessage = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send test notification');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
|
||||
} catch (error) {
|
||||
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
} finally {
|
||||
testLoading = false;
|
||||
|
||||
// Auto-dismiss message after 3 seconds
|
||||
setTimeout(() => {
|
||||
testMessage = null;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,7 +114,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if state.error}
|
||||
{#if viewModel.error}
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -89,14 +122,14 @@
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-red-800">Error</div>
|
||||
<div class="text-sm text-red-700">{state.error}</div>
|
||||
<div class="text-sm text-red-700">{viewModel.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Browser Support Info -->
|
||||
{#if !state.supported}
|
||||
{#if !viewModel.supported}
|
||||
<div class="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -113,7 +146,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Permission Denied Info -->
|
||||
{#if state.permission === 'denied'}
|
||||
{#if viewModel.permission === 'denied'}
|
||||
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -130,7 +163,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Features List -->
|
||||
{#if state.supported && state.permission !== 'denied'}
|
||||
{#if viewModel.supported && viewModel.permission !== 'denied'}
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-600 mb-2">You'll receive notifications for:</div>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
@@ -162,21 +195,71 @@
|
||||
<button
|
||||
onclick={handleToggle}
|
||||
disabled={!canToggle()}
|
||||
class="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors {state.subscribed
|
||||
class="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors {viewModel.subscribed
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50'} disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if state.loading}
|
||||
{#if viewModel.loading}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={state.subscribed ? "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728" : "M15 17h5l-5 5-5-5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"}></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={viewModel.subscribed ? "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728" : "M15 17h5l-5 5-5-5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{getButtonText()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Notification Buttons (only shown when subscribed) -->
|
||||
{#if viewModel.subscribed}
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Test Notifications</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Send a test notification to verify your subscription is working correctly.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
onclick={() => sendTestNotification('success')}
|
||||
disabled={testLoading || viewModel.loading}
|
||||
class="px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testLoading ? 'Sending...' : 'Test Success'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => sendTestNotification('error')}
|
||||
disabled={testLoading || viewModel.loading}
|
||||
class="px-3 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testLoading ? 'Sending...' : 'Test Error'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => sendTestNotification('progress')}
|
||||
disabled={testLoading || viewModel.loading}
|
||||
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testLoading ? 'Sending...' : 'Test Progress'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test Message -->
|
||||
{#if testMessage}
|
||||
<div class="mt-4 p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
|
||||
<div class="flex items-start space-x-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
|
||||
</svg>
|
||||
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
|
||||
{testMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
246
src/routes/components/NotificationSettings.svelte.spec.ts
Normal file
246
src/routes/components/NotificationSettings.svelte.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import NotificationSettings from './NotificationSettings.svelte';
|
||||
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||
|
||||
// Mock the pushNotificationManager
|
||||
vi.mock('$lib/client/PushNotificationManager', () => ({
|
||||
pushNotificationManager: {
|
||||
onStateChange: vi.fn(),
|
||||
toggleSubscription: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('NotificationSettings test buttons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock fetch using vi.stubGlobal for browser environment
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
test('should not show test buttons when not subscribed', async () => {
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: false,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
// Test Notifications section should not be visible
|
||||
const testSection = page.getByText('Test Notifications');
|
||||
await expect.element(testSection).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show test buttons when subscribed', async () => {
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
await expect.element(page.getByText('Test Success')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Test Error')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Test Progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should send test success notification on button click', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, subscriberCount: 1 })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const button = page.getByText('Test Success');
|
||||
await button.click();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'success' })
|
||||
});
|
||||
|
||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
||||
await expect.element(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should send test error notification on button click', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, subscriberCount: 2 })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const button = page.getByText('Test Error');
|
||||
await button.click();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'error' })
|
||||
});
|
||||
|
||||
const successMessage = page.getByText(/✓ Test error notification sent/i);
|
||||
await expect.element(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should send test progress notification on button click', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, subscriberCount: 1 })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const button = page.getByText('Test Progress');
|
||||
await button.click();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'progress' })
|
||||
});
|
||||
|
||||
const successMessage = page.getByText(/✓ Test progress notification sent/i);
|
||||
await expect.element(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display error message on failed request', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500
|
||||
} as Response);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const button = page.getByText('Test Success');
|
||||
await button.click();
|
||||
|
||||
const errorMessage = page.getByText(/✗ Error:/i);
|
||||
await expect.element(errorMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should auto-dismiss message after 3 seconds', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, subscriberCount: 1 })
|
||||
} as Response);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const button = page.getByText('Test Success');
|
||||
await button.click();
|
||||
|
||||
// Message should appear
|
||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
||||
await expect.element(successMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should disable buttons during loading', async () => {
|
||||
// Create a promise that we can control
|
||||
let resolvePromise: ((value: any) => void) | undefined;
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(fetch).mockReturnValue(fetchPromise as any);
|
||||
|
||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
||||
callback({
|
||||
supported: true,
|
||||
permission: 'granted',
|
||||
subscribed: true,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
return () => {};
|
||||
});
|
||||
|
||||
render(NotificationSettings);
|
||||
|
||||
const successButton = page.getByRole('button', { name: 'Test Success' });
|
||||
|
||||
// Click a button to start loading
|
||||
await successButton.click();
|
||||
|
||||
// Button should show "Sending..." text
|
||||
const sendingButton = page.getByRole('button', { name: 'Sending...' }).first();
|
||||
await expect.element(sendingButton).toBeInTheDocument();
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolvePromise?.({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, subscriberCount: 1 })
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user