This commit is contained in:
Giancarmine Salucci
2026-02-18 01:21:44 +01:00
parent 54321fd7c9
commit 49bccf8f15
84 changed files with 14474 additions and 13925 deletions

View File

@@ -3,10 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",

View File

@@ -5,6 +5,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
## 🚀 Features
### Core Functionality
- **Async Queue Processing**: Fire-and-forget recipe extraction with background processing
- **Real-time Updates**: Server-Sent Events for live progress tracking
- **Push Notifications**: Background notifications when recipes complete
@@ -13,6 +14,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
- **PWA Support**: Installable Progressive Web App with offline capabilities
### User Experience
- **Queue Dashboard**: Monitor all recipe extractions in real-time
- **Share Integration**: Browser share target for easy URL submission
- **Responsive Design**: Works on desktop, tablet, and mobile
@@ -20,6 +22,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
- **Progress Tracking**: Visual progress through extraction phases
### Technical Architecture
- **SvelteKit Frontend**: Modern reactive UI with TypeScript
- **Hexagonal Architecture**: Clean separation of concerns
- **In-Memory Queue**: High-performance processing with configurable concurrency
@@ -29,6 +32,7 @@ A modern web application that extracts recipes from Instagram posts and saves th
## 📋 API Endpoints
### Queue Management
- `POST /api/queue` - Enqueue Instagram URL for processing
- `GET /api/queue` - List queue items with filtering and pagination
- `GET /api/queue/{id}` - Get specific queue item details
@@ -36,18 +40,21 @@ A modern web application that extracts recipes from Instagram posts and saves th
- `GET /api/queue/stream` - Server-Sent Events for real-time updates
### Push Notifications
- `POST /api/notifications/subscribe` - Subscribe to push notifications
- `DELETE /api/notifications/subscribe` - Unsubscribe from notifications
- `GET /api/notifications/vapid-key` - Get VAPID public key
### Legacy Endpoints (Deprecated)
- ~~`POST /api/extract`~~ - Use `/api/queue` instead
- ~~`GET /api/extract-stream`~~ - Use `/api/queue/stream` instead
## 🛠 Development Setup
### Prerequisites
- Node.js 18+
- Node.js 18+
- npm or pnpm
- Tandoor Recipe Manager instance (optional)
- LLM API access (OpenAI, Anthropic, or local)
@@ -79,6 +86,7 @@ open https://localhost:5173
```
The app runs on HTTPS by default for:
- Service worker support (required for PWA)
- Push notifications
- Browser share target API
@@ -89,6 +97,7 @@ The app runs on HTTPS by default for:
The application uses HTTPS in development with SSL certificates signed by an external Caddy CA container. The current certificate is valid until **December 20, 2035** (10 years).
**Certificate Information:**
- Location: `.ssl/` directory
- CA Certificate: `.ssl/root.crt` (already trusted on the system)
- Server Certificate: `.ssl/localhost.crt`
@@ -97,18 +106,21 @@ The application uses HTTPS in development with SSL certificates signed by an ext
Since the Caddy CA is already trusted on the system, the certificate should work without additional trust steps. If you encounter browser warnings:
**Linux (Ubuntu/Debian):**
```bash
sudo cp .ssl/root.crt /usr/local/share/ca-certificates/caddy-local.crt
sudo update-ca-certificates
```
**Chrome/Chromium:**
1. Go to `chrome://settings/certificates`
2. Click "Authorities" → "Import"
3. Select `.ssl/root.crt`
4. Check "Trust this certificate for identifying websites"
**Checking Certificate Expiration:**
```bash
openssl x509 -enddate -noout -in .ssl/localhost.crt
```
@@ -220,6 +232,7 @@ To enable web push notifications:
## 🏗 Architecture Overview
### Queue System
```
User submits URL → Queue Manager → Queue Processor
@@ -231,7 +244,7 @@ User submits URL → Queue Manager → Queue Processor
### Processing Pipeline
1. **Extraction Phase**: Browser automation extracts text and images
2. **Parsing Phase**: LLM converts text to structured recipe data
2. **Parsing Phase**: LLM converts text to structured recipe data
3. **Upload Phase**: Automatic upload to Tandoor (if configured)
Each phase tracks progress and can fail independently with proper error handling.
@@ -247,9 +260,9 @@ Each phase tracks progress and can fail independently with proper error handling
# Run all tests
npm test
# Run specific test suites
# Run specific test suites
npm run test:unit # Unit tests only
npm run test:client # Browser tests only
npm run test:client # Browser tests only
npm run test:server # Server tests only
# Run tests in watch mode
@@ -257,9 +270,10 @@ npm run test:watch
```
Test Coverage:
- **138 total tests** covering all major components
- Queue Manager: 28 tests
- Queue Processor: 5 integration tests
- Queue Processor: 5 integration tests
- API Endpoints: 17 tests
- SSE Streaming: 6 tests
- Frontend Components: Browser tests
@@ -279,11 +293,13 @@ npm run preview
### Deployment
The app is built as a Node.js application with the following outputs:
- `/.svelte-kit/output/server/` - Server bundle
- `/.svelte-kit/output/client/` - Static assets
- `/.svelte-kit/output/client/` - Static assets
- `/build/` - Adapter output
Deploy the server bundle with:
```bash
node build/index.js
```
@@ -307,13 +323,15 @@ CMD ["node", "build"]
The app was migrated from a synchronous extraction system to an async queue-based system:
**Before (Synchronous)**:
- User waited for entire extraction process to complete
- No progress tracking during processing
- No retry capability for failures
- No retry capability for failures
- Single-threaded processing
- Limited error handling
**After (Async Queue)**:
- Fire-and-forget: submit URL and redirect immediately
- Real-time progress tracking via SSE
- Comprehensive retry system for failures
@@ -324,16 +342,18 @@ The app was migrated from a synchronous extraction system to an async queue-base
### API Migration
**Old Synchronous Endpoints** (Deprecated):
```bash
POST /api/extract # Submit URL and wait for completion
GET /api/extract-stream # Long-polling for progress
```
**New Queue Endpoints**:
```bash
POST /api/queue # Submit URL, get queue ID immediately
GET /api/queue # List all queue items
GET /api/queue/{id} # Get specific item status
GET /api/queue/{id} # Get specific item status
POST /api/queue/{id}/retry # Retry failed items
GET /api/queue/stream # Real-time SSE updates
```
@@ -344,13 +364,14 @@ If migrating from the old system:
1. **Update Client Code**: Replace `/api/extract` calls with `/api/queue`
2. **Handle Async Responses**: Process queue ID instead of waiting for completion
3. **Add Progress Tracking**: Implement SSE listeners for real-time updates
3. **Add Progress Tracking**: Implement SSE listeners for real-time updates
4. **Update Error Handling**: Handle new error classification system
5. **Add Retry Logic**: Implement retry functionality for failed items
### Backward Compatibility
The legacy endpoints are still available but deprecated:
- They will return `410 Gone` status with migration instructions
- Support will be removed in a future version
- All new development should use the queue endpoints
@@ -383,4 +404,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail
- [Tandoor Recipe Manager](https://docs.tandoor.dev/) - Recipe management system
- [Workbox](https://developers.google.com/web/tools/workbox) - PWA capabilities
- [fastq](https://github.com/mcollina/fastq) - High-performance queue processing

View File

@@ -4,34 +4,34 @@ services:
container_name: insta-recipe
network_mode: host
ports:
- "3000:3000"
- '3000:3000'
environment:
# LLM Configuration (Required)
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LLM_MODEL=${LLM_MODEL:-google/gemma-3-4b}
# Queue Configuration (Optional)
- QUEUE_CONCURRENCY=${QUEUE_CONCURRENCY:-2}
- QUEUE_MAX_RETRIES=${QUEUE_MAX_RETRIES:-3}
# Tandoor Integration (Optional)
- TANDOOR_ENABLED=${TANDOOR_ENABLED:-false}
- TANDOOR_SERVER_URL=${TANDOOR_SERVER_URL}
- TANDOOR_SPACE=${TANDOOR_SPACE:-1}
- TANDOOR_TOKEN=${TANDOOR_TOKEN}
# Push Notifications (Optional)
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
# Authentication Scheduler (Optional)
- AUTH_SCHEDULER_ENABLED=${AUTH_SCHEDULER_ENABLED:-false}
- AUTH_SCHEDULER_INTERVAL_MINUTES=${AUTH_SCHEDULER_INTERVAL_MINUTES:-720}
# Playwright Configuration
- DISPLAY=:99
# Node.js Environment
- NODE_ENV=production
security_opt:
@@ -40,8 +40,14 @@ services:
- ./secrets:/app/secrets
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
test:
[
'CMD',
'node',
'-e',
"fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
start_period: 40s

View File

@@ -2,7 +2,7 @@ services:
app:
build: .
ports:
- "5173:5173"
- '5173:5173'
environment:
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
- OPENAI_BASE_URL=http://ollama:11434/v1
@@ -18,7 +18,7 @@ services:
playwright-service:
build: ./playwright-service
ipc: host
ports: ["3000:3000"]
ports: ['3000:3000']
environment:
- DISPLAY=:99
security_opt:
@@ -26,9 +26,9 @@ services:
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
ports: ['11434:11434']
volumes:
- ollama_data:/root/.ollama
volumes:
ollama_data:
ollama_data:

View File

@@ -5,6 +5,7 @@ This document describes the InstaRecipe API endpoints for the async queue-based
## Base URL
All API endpoints are relative to your InstaRecipe instance:
```
https://your-instarecipe-instance.com/api
```
@@ -23,13 +24,16 @@ All endpoints return standardized error responses:
```json
{
"error": "Error type",
"message": "Human-readable error message",
"details": { /* Additional error context */ }
"error": "Error type",
"message": "Human-readable error message",
"details": {
/* Additional error context */
}
}
```
HTTP status codes follow REST conventions:
- `200` - Success
- `201` - Created
- `400` - Bad Request (invalid input)
@@ -45,13 +49,15 @@ HTTP status codes follow REST conventions:
Enqueue an Instagram URL for async processing.
**Request:**
```json
{
"url": "https://instagram.com/p/abc123"
"url": "https://instagram.com/p/abc123"
}
```
**Supported URL Formats:**
- Posts: `https://instagram.com/p/{post-id}`
- Posts (www): `https://www.instagram.com/p/{post-id}`
- Reels: `https://instagram.com/reel/{reel-id}`
@@ -59,12 +65,14 @@ Enqueue an Instagram URL for async processing.
- With query parameters: `https://instagram.com/reel/{reel-id}?utm_source=share`
**URL Requirements:**
- Must use HTTPS protocol
- Hostname must be `instagram.com` or `www.instagram.com`
- Any Instagram path is accepted (posts, reels, IGTV, etc.)
- Query parameters and hash fragments are allowed
**Examples:**
```json
// Post URL
{ "url": "https://instagram.com/p/ABC123" }
@@ -77,34 +85,36 @@ Enqueue an Instagram URL for async processing.
```
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link",
"status": "pending",
"phases": [
{
"name": "extraction",
"status": "pending",
"progress": 0
},
{
"name": "parsing",
"status": "pending",
"progress": 0
},
{
"name": "uploading",
"status": "pending",
"progress": 0
}
],
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:00Z"
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link",
"status": "pending",
"phases": [
{
"name": "extraction",
"status": "pending",
"progress": 0
},
{
"name": "parsing",
"status": "pending",
"progress": 0
},
{
"name": "uploading",
"status": "pending",
"progress": 0
}
],
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:00Z"
}
```
**Errors:**
- `400` - Invalid URL format (not a valid URL)
- `400` - URL must use HTTPS protocol
- `400` - URL must be from instagram.com domain
@@ -115,6 +125,7 @@ Enqueue an Instagram URL for async processing.
List queue items with optional filtering, pagination, and sorting.
**Query Parameters:**
- `status` (optional): Filter by status (`pending`, `in_progress`, `success`, `error`, `unhealthy`)
- `limit` (optional): Number of items to return (default: 50, max: 100)
- `offset` (optional): Number of items to skip (default: 0)
@@ -122,6 +133,7 @@ List queue items with optional filtering, pagination, and sorting.
- `order` (optional): Sort order (`asc`, `desc`) (default: `desc`)
**Examples:**
```bash
GET /api/queue # All items
GET /api/queue?status=error # Failed items only
@@ -130,67 +142,68 @@ GET /api/queue?sort=status&order=asc # Sort by status
```
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/p/abc123",
"status": "success",
"phases": [
{
"name": "extraction",
"status": "completed",
"startedAt": "2024-12-21T10:30:01Z",
"completedAt": "2024-12-21T10:30:15Z",
"progress": 100
},
{
"name": "parsing",
"status": "completed",
"startedAt": "2024-12-21T10:30:15Z",
"completedAt": "2024-12-21T10:30:25Z",
"progress": 100
},
{
"name": "uploading",
"status": "completed",
"startedAt": "2024-12-21T10:30:25Z",
"completedAt": "2024-12-21T10:30:30Z",
"progress": 100
}
],
"results": {
"recipe": {
"name": "Chocolate Chip Cookies",
"description": "Delicious homemade cookies",
"servings": 24,
"ingredients": [
{
"food": "flour",
"amount": 2.25,
"unit": "cups"
}
],
"steps": [
{
"instruction": "Preheat oven to 375°F",
"time": 5
}
],
"keywords": ["cookies", "dessert", "chocolate"],
"image": "https://instagram.com/image.jpg"
},
"tandoorUrl": "https://tandoor.example.com/recipe/123",
"extractedText": "Raw extracted text...",
"thumbnail": "https://instagram.com/thumbnail.jpg"
},
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:30Z"
}
],
"total": 42,
"hasMore": true
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://instagram.com/p/abc123",
"status": "success",
"phases": [
{
"name": "extraction",
"status": "completed",
"startedAt": "2024-12-21T10:30:01Z",
"completedAt": "2024-12-21T10:30:15Z",
"progress": 100
},
{
"name": "parsing",
"status": "completed",
"startedAt": "2024-12-21T10:30:15Z",
"completedAt": "2024-12-21T10:30:25Z",
"progress": 100
},
{
"name": "uploading",
"status": "completed",
"startedAt": "2024-12-21T10:30:25Z",
"completedAt": "2024-12-21T10:30:30Z",
"progress": 100
}
],
"results": {
"recipe": {
"name": "Chocolate Chip Cookies",
"description": "Delicious homemade cookies",
"servings": 24,
"ingredients": [
{
"food": "flour",
"amount": 2.25,
"unit": "cups"
}
],
"steps": [
{
"instruction": "Preheat oven to 375°F",
"time": 5
}
],
"keywords": ["cookies", "dessert", "chocolate"],
"image": "https://instagram.com/image.jpg"
},
"tandoorUrl": "https://tandoor.example.com/recipe/123",
"extractedText": "Raw extracted text...",
"thumbnail": "https://instagram.com/thumbnail.jpg"
},
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:30Z"
}
],
"total": 42,
"hasMore": true
}
```
@@ -199,12 +212,14 @@ GET /api/queue?sort=status&order=asc # Sort by status
Get details for a specific queue item.
**Path Parameters:**
- `id`: Queue item UUID
**Response (200 OK):**
Returns the same queue item structure as in the list response.
**Errors:**
- `400` - Invalid UUID format
- `404` - Queue item not found
@@ -213,22 +228,25 @@ Returns the same queue item structure as in the list response.
Retry a failed queue item. Only items with `error` or `unhealthy` status can be retried.
**Path Parameters:**
- `id`: Queue item UUID
**Response (200 OK):**
```json
{
"success": true,
"message": "Item queued for retry",
"item": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"updatedAt": "2024-12-21T11:00:00Z"
}
"success": true,
"message": "Item queued for retry",
"item": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"updatedAt": "2024-12-21T11:00:00Z"
}
}
```
**Errors:**
- `400` - Invalid UUID format
- `404` - Queue item not found
- `409` - Cannot retry item with current status (only `error` and `unhealthy` can be retried)
@@ -240,10 +258,12 @@ Retry a failed queue item. Only items with `error` or `unhealthy` status can be
Server-Sent Events (SSE) endpoint for real-time queue updates.
**Query Parameters:**
- `itemId` (optional): Filter updates for specific item
- `status` (optional): Filter updates by status
**Headers:**
```
Accept: text/event-stream
Cache-Control: no-cache
@@ -253,19 +273,23 @@ Cache-Control: no-cache
SSE stream with the following event types:
#### connection
Sent when connection is established:
```
event: connection
data: {"message": "Connected to queue stream", "timestamp": "2024-12-21T10:30:00Z"}
```
#### queue-update
#### queue-update
Sent when queue item status changes:
```
event: queue-update
data: {
"itemId": "550e8400-e29b-41d4-a716-446655440000",
"status": "in_progress",
"status": "in_progress",
"timestamp": "2024-12-21T10:30:01Z",
"progress": [
{
@@ -279,7 +303,9 @@ data: {
```
#### ping
Keep-alive ping sent every 30 seconds:
```
event: ping
data: {"timestamp": "2024-12-21T10:30:30Z"}
@@ -288,30 +314,32 @@ data: {"timestamp": "2024-12-21T10:30:30Z"}
**Usage Examples:**
**JavaScript:**
```javascript
const eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('connection', (event) => {
console.log('Connected:', JSON.parse(event.data));
console.log('Connected:', JSON.parse(event.data));
});
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
console.log('Queue update:', update);
updateUI(update);
const update = JSON.parse(event.data);
console.log('Queue update:', update);
updateUI(update);
});
eventSource.addEventListener('ping', (event) => {
console.log('Keep-alive ping');
console.log('Keep-alive ping');
});
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Reconnect logic here
console.error('SSE error:', error);
// Reconnect logic here
};
```
**curl:**
```bash
curl -N -H "Accept: text/event-stream" \
"https://your-instance.com/api/queue/stream?itemId=550e8400-e29b-41d4-a716-446655440000"
@@ -324,10 +352,11 @@ curl -N -H "Accept: text/event-stream" \
Get the VAPID public key required for push notification subscriptions.
**Response (200 OK):**
```json
{
"publicKey": "BDummyPublicKeyForDevelopment...",
"applicationServerKey": "BDummyPublicKeyForDevelopment..."
"publicKey": "BDummyPublicKeyForDevelopment...",
"applicationServerKey": "BDummyPublicKeyForDevelopment..."
}
```
@@ -336,29 +365,32 @@ Get the VAPID public key required for push notification subscriptions.
Subscribe to push notifications for queue processing updates.
**Request:**
```json
{
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BIBn3E_YUVpW5f6_Eq_GH...",
"auth": "tBiH_Y1nPSuVh7TRMhcf..."
}
},
"clientId": "unique-client-identifier"
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BIBn3E_YUVpW5f6_Eq_GH...",
"auth": "tBiH_Y1nPSuVh7TRMhcf..."
}
},
"clientId": "unique-client-identifier"
}
```
**Response (200 OK):**
```json
{
"success": true,
"message": "Successfully subscribed to push notifications",
"subscriptionCount": 5
"success": true,
"message": "Successfully subscribed to push notifications",
"subscriptionCount": 5
}
```
**Errors:**
- `400` - Invalid subscription object or missing clientId
### DELETE /api/notifications/subscribe
@@ -366,18 +398,20 @@ Subscribe to push notifications for queue processing updates.
Unsubscribe from push notifications.
**Request:**
```json
{
"clientId": "unique-client-identifier"
"clientId": "unique-client-identifier"
}
```
**Response (200 OK):**
```json
{
"success": true,
"message": "Successfully unsubscribed from push notifications",
"subscriptionCount": 4
"success": true,
"message": "Successfully unsubscribed from push notifications",
"subscriptionCount": 4
}
```
@@ -390,18 +424,19 @@ Unsubscribe from push notifications.
This synchronous extraction endpoint is deprecated. Use `POST /api/queue` instead.
**Migration:**
```javascript
// ❌ Old synchronous approach
const response = await fetch('/api/extract', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const result = await response.json(); // Wait 30-60 seconds
// ✅ New async queue approach
// ✅ New async queue approach
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const queueItem = await response.json(); // Immediate response
```
@@ -413,17 +448,18 @@ const queueItem = await response.json(); // Immediate response
This SSE endpoint is deprecated. Use `POST /api/queue` + `GET /api/queue/stream` instead.
**Migration:**
```javascript
// ❌ Old approach
const response = await fetch('/api/extract-stream', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
// ✅ New approach
const queueResponse = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const item = await queueResponse.json();
@@ -436,28 +472,28 @@ const eventSource = new EventSource(`/api/queue/stream?itemId=${item.id}`);
```typescript
interface QueueItem {
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string; // ISO 8601 timestamp
completedAt?: string; // ISO 8601 timestamp
progress?: number; // 0-100
}>;
results?: {
recipe?: Recipe; // Structured recipe data
tandoorUrl?: string; // Tandoor recipe URL
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
error?: string; // Error message
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string; // ISO 8601 timestamp
completedAt?: string; // ISO 8601 timestamp
progress?: number; // 0-100
}>;
results?: {
recipe?: Recipe; // Structured recipe data
tandoorUrl?: string; // Tandoor recipe URL
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
error?: string; // Error message
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
@@ -465,32 +501,33 @@ interface QueueItem {
```typescript
interface Recipe {
name: string;
description?: string;
servings?: number;
prepTime?: number; // Minutes
cookTime?: number; // Minutes
totalTime?: number; // Minutes
ingredients: Array<{
food: string;
amount?: number;
unit?: string;
}>;
steps: Array<{
instruction: string;
time?: number; // Minutes
}>;
keywords?: string[]; // Recipe tags
image?: string; // Image URL
nutrition?: { // Nutritional information
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
};
name: string;
description?: string;
servings?: number;
prepTime?: number; // Minutes
cookTime?: number; // Minutes
totalTime?: number; // Minutes
ingredients: Array<{
food: string;
amount?: number;
unit?: string;
}>;
steps: Array<{
instruction: string;
time?: number; // Minutes
}>;
keywords?: string[]; // Recipe tags
image?: string; // Image URL
nutrition?: {
// Nutritional information
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
};
}
```
@@ -517,59 +554,58 @@ When implementing clients, consider these error recovery strategies:
```javascript
async function processInstagramUrl(url) {
try {
// 1. Enqueue URL
const queueResponse = await fetch('/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const queueItem = await queueResponse.json();
console.log('Enqueued:', queueItem.id);
// 2. Listen for real-time updates
const eventSource = new EventSource(`/api/queue/stream?itemId=${queueItem.id}`);
return new Promise((resolve, reject) => {
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
if (update.status === 'success') {
eventSource.close();
resolve(update.results);
} else if (update.status === 'error') {
eventSource.close();
reject(new Error(update.error));
}
// Handle progress updates
console.log('Progress:', update.progress);
});
eventSource.onerror = (error) => {
eventSource.close();
reject(error);
};
});
} catch (error) {
console.error('Processing failed:', error);
throw error;
}
try {
// 1. Enqueue URL
const queueResponse = await fetch('/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const queueItem = await queueResponse.json();
console.log('Enqueued:', queueItem.id);
// 2. Listen for real-time updates
const eventSource = new EventSource(`/api/queue/stream?itemId=${queueItem.id}`);
return new Promise((resolve, reject) => {
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
if (update.status === 'success') {
eventSource.close();
resolve(update.results);
} else if (update.status === 'error') {
eventSource.close();
reject(new Error(update.error));
}
// Handle progress updates
console.log('Progress:', update.progress);
});
eventSource.onerror = (error) => {
eventSource.close();
reject(error);
};
});
} catch (error) {
console.error('Processing failed:', error);
throw error;
}
}
// Usage
processInstagramUrl('https://instagram.com/p/abc123')
.then(results => {
console.log('Recipe extracted:', results.recipe);
if (results.tandoorUrl) {
console.log('Uploaded to Tandoor:', results.tandoorUrl);
}
})
.catch(error => {
console.error('Extraction failed:', error.message);
});
.then((results) => {
console.log('Recipe extracted:', results.recipe);
if (results.tandoorUrl) {
console.log('Uploaded to Tandoor:', results.tandoorUrl);
}
})
.catch((error) => {
console.error('Extraction failed:', error.message);
});
```
For more examples and integration guides, see the [Migration Documentation](/docs/MIGRATION.md).
For more examples and integration guides, see the [Migration Documentation](/docs/MIGRATION.md).

View File

@@ -91,21 +91,27 @@ insta-recipe/
## Key Directories
### `/src/lib/server/`
Server-side business logic following Hexagonal Architecture principles. Contains domain logic, adapters for external systems (Instagram, Tandoor, LLM), and port definitions.
### `/src/lib/client/`
Client-side utilities for PWA features (push notifications, install prompts, service worker messaging).
### `/src/routes/api/`
RESTful API endpoints implemented as SvelteKit server routes. Each directory contains `+server.ts` files exporting HTTP verb handlers.
### `/src/routes/share/`
Share target page allowing users to share Instagram URLs directly from their browser or mobile apps.
### `/src/lib/server/queue/`
Queue management system with in-memory storage, processor workers, and type definitions.
### `/docs/`
Comprehensive documentation including plans, outcomes, API specs, and migration guides.
---
@@ -113,33 +119,43 @@ Comprehensive documentation including plans, outcomes, API specs, and migration
## Design Patterns
### Singleton Pattern
Used for shared service instances:
- `QueueManager` (`queueManager` exported instance)
- `QueueProcessor` (`queueProcessor` exported instance)
- `PushNotificationService` (`pushNotificationService` exported instance)
- `ServiceWorkerMessageHandler` (`serviceWorkerMessageHandler` exported instance)
### Factory Pattern
Used for creating configured instances:
- `createLLM()` - Creates OpenAI client with environment configuration
- `createBrowserContext()` - Creates Playwright browser context with options
- `initializeBrowser()` - Initializes Chromium browser instance
### Observer Pattern
Implemented in QueueManager for real-time updates:
- Subscribers receive notifications on queue item changes
- Server-Sent Events (SSE) stream queue updates to clients
- Push notifications notify users of completion events
### Adapter Pattern (Hexagonal Architecture)
External systems accessed via adapters:
- **Instagram Adapter**: `extraction.ts` - Extracts content via Playwright
- **LLM Adapter**: `llm.ts`, `parser.ts` - Recipe parsing via OpenAI
- **Tandoor Adapter**: `tandoor.ts` - Recipe management system integration
- **Browser Adapter**: `browser.ts` - Playwright browser automation
### Strategy Pattern
Multiple extraction strategies with fallback:
1. Embedded JSON extraction
2. DOM selector extraction
3. GraphQL API extraction
@@ -150,28 +166,34 @@ Multiple extraction strategies with fallback:
## Key Components
### Queue Management System
**Location**: `src/lib/server/queue/`
Three-phase processing pipeline:
1. **Extraction Phase**: Extract text and thumbnail from Instagram
2. **Parsing Phase**: Parse recipe using LLM
3. **Uploading Phase**: Upload to Tandoor (if enabled)
**Components**:
- `QueueManager`: In-memory FIFO queue with CRUD operations
- `QueueProcessor`: Worker that processes items with configurable concurrency
- `types.ts`: Comprehensive type definitions for queue items and updates
### API Layer
**Location**: `src/routes/api/`
RESTful endpoints for:
- Queue operations (`POST /api/queue`, `GET /api/queue`, `GET /api/queue/[id]`)
- Real-time updates (`GET /api/queue/stream` - SSE)
- Push notifications (`POST /api/notifications/subscribe`)
- Health checks (`GET /api/health`, `GET /api/llm-health`)
### Client-Side Services
**Location**: `src/lib/client/`
- **PushNotificationManager**: Manages Web Push API subscriptions
@@ -179,14 +201,17 @@ RESTful endpoints for:
- **ServiceWorkerMessageHandler**: Processes service worker messages
### Instagram Extraction
**Location**: `src/lib/server/extraction.ts`
Multi-method extraction with intelligent fallback:
- Progress callbacks for real-time feedback
- Retry logic with configurable attempts
- Thumbnail extraction and validation
### LLM Integration
**Location**: `src/lib/server/parser.ts`, `src/lib/server/llm.ts`
- Recipe detection endpoint
@@ -198,6 +223,7 @@ Multi-method extraction with intelligent fallback:
## Dependencies
### Production Dependencies
- **@types/uuid** (^10.0.0) - UUID type definitions
- **date-fns** (^4.1.0) - Date utility library
- **openai** (^4.20.0) - OpenAI API client
@@ -206,6 +232,7 @@ Multi-method extraction with intelligent fallback:
- **zod** (^3.23.0) - Schema validation
### Development Dependencies
- **@sveltejs/kit** (^2.48.5) - SvelteKit framework
- **@sveltejs/adapter-node** (^5.4.0) - Node.js adapter
- **svelte** (^5.43.8) - Svelte 5 framework
@@ -223,12 +250,14 @@ Multi-method extraction with intelligent fallback:
## Module Organization
### SvelteKit Path Aliases
- `$lib``src/lib/`
- `$lib/*``src/lib/*`
- `$app/*` → SvelteKit app imports
- `$env/dynamic/private` → Environment variables (server-side)
### Directory Structure Conventions
- **Server-only code**: `src/lib/server/` (not bundled to client)
- **Client-only code**: `src/lib/client/` (not executed on server)
- **Shared code**: `src/lib/` (available to both)
@@ -240,6 +269,7 @@ Multi-method extraction with intelligent fallback:
## Data Flow
### Recipe Extraction Flow
```
User submits URL
@@ -261,6 +291,7 @@ SSE updates notify client
```
### Real-time Updates Flow
```
Client connects to GET /api/queue/stream (SSE)
@@ -274,6 +305,7 @@ Client updates UI reactively
```
### Push Notification Flow
```
Client requests permission
@@ -295,37 +327,44 @@ Notification displayed to user
## Build System
### Build Command
```bash
npm run build
```
Generates production-ready build in `build/` directory using:
- Vite for bundling
- `@sveltejs/adapter-node` for Node.js deployment
- TypeScript compilation
- SvelteKit prerendering and optimization
### Test Command
```bash
npm test
```
Runs test suite using Vitest with two projects:
1. **Server tests**: Node environment for server-side code
2. **Client tests**: Playwright browser for Svelte components
### Development Server
```bash
npm run dev
```
Starts Vite dev server with:
- HTTPS enabled (certificates in `.ssl/`)
- Hot module replacement
- TypeScript checking
- File watching
### Linting & Formatting
```bash
npm run lint # ESLint + Prettier check
npm run format # Prettier write
@@ -336,19 +375,24 @@ npm run format # Prettier write
## Deployment
### Docker Deployment
Dockerfile includes:
- Node.js 22 Alpine base image
- Playwright Chromium installation
- Production build
- Port 3000 exposure
Run with:
```bash
docker-compose up
```
### Environment Variables
Required configuration:
- `OPENAI_API_KEY` - LLM API access
- `TANDOOR_URL` - Tandoor instance URL (optional)
- `TANDOOR_TOKEN` - Tandoor API token (optional)
@@ -360,13 +404,16 @@ Required configuration:
## Testing Architecture
### Test Categories
1. **Unit Tests**: Individual function testing
2. **Integration Tests**: Multi-component workflows
3. **API Tests**: Endpoint behavior validation
4. **Browser Tests**: Svelte component rendering
### Test Coverage
138 tests covering:
- Queue management operations
- Instagram URL validation
- SSE streaming
@@ -375,6 +422,7 @@ Required configuration:
- Notification service
### Test Configuration
- **Server tests**: Node environment with mocked dependencies
- **Client tests**: Playwright Chromium browser with Svelte testing library
@@ -383,15 +431,18 @@ Required configuration:
## Security Considerations
### SSL/TLS
- Development uses local SSL certificates signed by external Caddy CA
- Certificates stored in `.ssl/` (git-ignored)
- Required for PWA features (Service Worker, Push API)
### Authentication
- Basic auth for scheduled tasks (username/password from environment)
- Tandoor integration uses bearer token authentication
### Input Validation
- Instagram URL validation with regex patterns
- Zod schema validation for API payloads
- Error handling with custom error classes

View File

@@ -19,12 +19,14 @@
### Files & Directories
#### SvelteKit Route Files
- Route pages: `+page.svelte`
- Route servers: `+server.ts`
- Route layouts: `+layout.svelte`
- Type definitions: `$types.ts` (auto-generated)
**Example:**
```
src/routes/api/queue/
├── [id]/
@@ -37,19 +39,23 @@ src/routes/api/queue/
```
#### Library Files
- **PascalCase** for classes and managers: `QueueManager.ts`, `PushNotificationService.ts`
- **kebab-case** for utilities and configs: `tandoor-config.ts`, `instagram-url.ts`
- **lowercase** for general modules: `browser.ts`, `extraction.ts`, `parser.ts`
**Examples from codebase:**
- `src/lib/server/queue/QueueManager.ts`
- `src/lib/server/tandoor-config.ts`
- `src/lib/client/PushNotificationManager.ts`
#### Test Files
Pattern: `<name>.spec.ts` or `<name>.test.ts`
**Examples:**
- `queue-manager.spec.ts`
- `instagram-url-validation.spec.ts`
- `page.svelte.spec.ts`
@@ -57,10 +63,12 @@ Pattern: `<name>.spec.ts` or `<name>.test.ts`
### Variables & Functions
#### Variables
- **camelCase** for local variables and parameters
- **SCREAMING_SNAKE_CASE** for constants
**Examples:**
```typescript
// From QueueManager.ts
private items: Map<string, QueueItem> = new Map();
@@ -76,10 +84,12 @@ const unsubscribe = queueManager.subscribe(callback);
```
#### Functions
- **camelCase** for function names
- **Descriptive action verbs**: `enqueue`, `extractRecipe`, `uploadRecipeImage`
**Examples:**
```typescript
// From QueueManager.ts
enqueue(url: string): QueueItem { ... }
@@ -99,62 +109,62 @@ export async function extractRecipe(text: string): Promise<Recipe> { ... }
### Types & Interfaces
#### Interfaces & Types
- **PascalCase** for interface names
- Prefix with `I` is **NOT** used
- Exported types use `export type` or `export interface`
**Examples:**
```typescript
// From queue/types.ts
export interface QueueItem {
id: string;
url: string;
status: QueueItemStatus;
enqueuedAt: string;
// ...
id: string;
url: string;
status: QueueItemStatus;
enqueuedAt: string;
// ...
}
export interface QueueStatusUpdate {
type: string;
itemId: string;
status: QueueItemStatus;
// ...
type: string;
itemId: string;
status: QueueItemStatus;
// ...
}
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed';
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
// From extraction.ts
export interface ExtractedContent {
text: string;
thumbnailUrl?: string;
text: string;
thumbnailUrl?: string;
}
export type ProgressCallback = (event: ProgressEvent) => void;
```
#### Zod Schemas
- **PascalCase** with `Schema` suffix
- Inferred types without suffix
**Examples:**
```typescript
// From parser.ts
const RecipeSchema = z.object({
name: z.string(),
description: z.string(),
servings: z.number(),
// ...
name: z.string(),
description: z.string(),
servings: z.number()
// ...
});
export type Recipe = z.infer<typeof RecipeSchema>;
// From tandoor.ts
const TandoorRecipeSchema = z.object({
// ...
// ...
});
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
@@ -163,35 +173,38 @@ export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
### Classes
#### Class Names
- **PascalCase** for class names
- Descriptive suffixes: `Manager`, `Service`, `Processor`, `Handler`
**Examples:**
```typescript
// From QueueManager.ts
export class QueueManager {
private items: Map<string, QueueItem> = new Map();
// ...
private items: Map<string, QueueItem> = new Map();
// ...
}
// From QueueProcessor.ts
export class QueueProcessor {
private processing: Set<string> = new Set();
// ...
private processing: Set<string> = new Set();
// ...
}
// From PushNotificationService.ts
class PushNotificationService {
private subscriptions: Map<string, PushSubscription> = new Map();
// ...
private subscriptions: Map<string, PushSubscription> = new Map();
// ...
}
```
#### Singleton Export Pattern
```typescript
// Class definition
export class QueueManager {
// Implementation
// Implementation
}
// Singleton instance export
@@ -203,6 +216,7 @@ export const queueManager = new QueueManager();
## Indentation & Formatting
### General Rules
- **Indentation:** 2 spaces (enforced by Prettier)
- **No tabs**
- **Max line length:** 100 characters (soft limit, not enforced)
@@ -213,6 +227,7 @@ export const queueManager = new QueueManager();
### Code Examples
#### Function Declarations
```typescript
// From QueueManager.ts
enqueue(url: string): QueueItem {
@@ -234,43 +249,45 @@ enqueue(url: string): QueueItem {
retryCount: 0,
maxRetries: 3
};
this.items.set(item.id, item);
return item;
}
```
#### Async Functions
```typescript
// From extraction.ts
export async function extractTextAndThumbnail(
url: string,
onProgress?: ProgressCallback
url: string,
onProgress?: ProgressCallback
): Promise<ExtractedContent> {
const browser = await getBrowser();
const context = await createBrowserContext(browser);
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle' });
// ...
} finally {
await context.close();
}
const browser = await getBrowser();
const context = await createBrowserContext(browser);
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle' });
// ...
} finally {
await context.close();
}
}
```
#### Object Destructuring
```typescript
// From route handlers
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
// ...
const { url } = await request.json();
// ...
};
export const GET: RequestHandler = async ({ params }) => {
const { id } = params;
// ...
const { id } = params;
// ...
};
```
@@ -279,12 +296,14 @@ export const GET: RequestHandler = async ({ params }) => {
## Import Patterns
### Import Order
1. External dependencies (Node.js built-ins, npm packages)
2. SvelteKit imports (`$lib`, `$app`, `$env`)
3. Relative imports (`./ `, `../`)
4. Type imports (separate from value imports when beneficial)
**Example:**
```typescript
// From QueueProcessor.ts
@@ -307,6 +326,7 @@ import type { QueueItem } from './types';
### Import Styles
#### Named Imports (Preferred)
```typescript
import { json } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
@@ -314,12 +334,14 @@ import { validateInstagramUrl } from '$lib/server/validation/instagram-url';
```
#### Type-Only Imports
```typescript
import type { RequestHandler } from './$types';
import type { QueueItem, QueueItemStatus } from './types';
```
#### Default Imports
```typescript
import OpenAI from 'openai';
import fs from 'fs';
@@ -329,6 +351,7 @@ import path from 'path';
### Export Patterns
#### Named Exports (Preferred)
```typescript
// Export functions
export async function extractRecipe(text: string): Promise<Recipe> { ... }
@@ -345,6 +368,7 @@ export interface QueueItem { ... }
```
#### Singleton Pattern Export
```typescript
// Define class
export class QueueManager { ... }
@@ -358,16 +382,18 @@ export const queueManager = new QueueManager();
## Comments & Documentation
### JSDoc Style
Used extensively for public APIs and exported functions.
**Function Documentation:**
```typescript
````typescript
/**
* Add URL to processing queue
*
*
* @param url - Instagram URL to process
* @returns Newly created queue item
*
*
* @example
* ```typescript
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
@@ -377,41 +403,43 @@ Used extensively for public APIs and exported functions.
enqueue(url: string): QueueItem {
// Implementation
}
```
````
**Class Documentation:**
```typescript
````typescript
/**
* Singleton queue manager for processing Instagram URLs
*
*
* Features:
* - FIFO queue with unique IDs
* - Status tracking and updates
* - Progress event accumulation
* - Retry support for failed items
* - Pub/sub for real-time updates
*
*
* @example
* ```typescript
* import { queueManager } from './QueueManager';
*
*
* // Add item to queue
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
* ```
*/
export class QueueManager {
// Implementation
// Implementation
}
```
````
**Module-Level Documentation:**
```typescript
/**
* Queue Manager - Core queue operations and event management
*
*
* Manages an in-memory queue of Instagram URL processing jobs.
* Provides CRUD operations and pub/sub mechanism for queue updates.
*
*
* Architecture: Domain Layer (Hexagonal Architecture)
* - Port: Defines queue operations interface
* - Implementation: In-memory Map-based storage
@@ -421,19 +449,21 @@ export class QueueManager {
### Inline Comments
#### Single-line Comments
```typescript
// Set restrictive permissions
fs.chmodSync(authFile, 0o600);
// FIFO order - get oldest pending item
const pendingItems = Array.from(this.items.values())
.filter(item => item.status === 'pending');
const pendingItems = Array.from(this.items.values()).filter((item) => item.status === 'pending');
```
#### Block Comments (Avoided)
Single-line comments preferred. Block comments used only for large comment blocks or temporarily disabling code during development.
### TODO Comments
```typescript
// TODO: Add retry logic with exponential backoff
// FIXME: Handle race condition when multiple workers dequeue
@@ -446,17 +476,19 @@ Single-line comments preferred. Block comments used only for large comment block
### Type Safety
#### Strict Mode Enabled
```json
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
}
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
}
}
```
#### Type Annotations
```typescript
// Explicit return types for public functions
export async function extractRecipe(text: string): Promise<Recipe> { ... }
@@ -469,35 +501,24 @@ const items = queueManager.getAll(); // Type inferred
```
### Union Types
```typescript
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed';
export type QueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export type ProcessingPhase =
| 'extraction'
| 'parsing'
| 'uploading';
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
export type ProgressEventType =
| 'status'
| 'method'
| 'retry'
| 'error'
| 'thumbnail'
| 'complete';
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
```
### Generics
```typescript
// Generic function
async function fetchFromTandoor<T>(
url: string,
options: Partial<RequestInit> = { method: 'GET' }
url: string,
options: Partial<RequestInit> = { method: 'GET' }
): Promise<{ ok: boolean; data?: T; error?: string }> {
// Implementation
// Implementation
}
```
@@ -508,6 +529,7 @@ async function fetchFromTandoor<T>(
### Runes (Reactivity)
#### $state (Reactive Variables)
```svelte
<script lang="ts">
let count = $state(0);
@@ -516,13 +538,14 @@ async function fetchFromTandoor<T>(
```
#### $props (Component Props)
```svelte
<script lang="ts">
let {
let {
recipe = null,
tandoorEnabled = false,
onRetry,
onImportToTandoor
onImportToTandoor
} = $props<{
recipe: Recipe | null;
tandoorEnabled: boolean;
@@ -533,6 +556,7 @@ async function fetchFromTandoor<T>(
```
#### $derived (Computed Values)
```svelte
<script lang="ts">
let count = $state(0);
@@ -541,10 +565,11 @@ async function fetchFromTandoor<T>(
```
#### $effect (Side Effects)
```svelte
<script lang="ts">
let url = $state('');
$effect(() => {
console.log('URL changed:', url);
});
@@ -552,25 +577,26 @@ async function fetchFromTandoor<T>(
```
### Component Structure
```svelte
<script lang="ts">
// Imports
import { onMount } from 'svelte';
// Props
let { items } = $props<{ items: Item[] }>();
// State
let loading = $state(false);
// Derived state
let count = $derived(items.length);
// Functions
function handleClick() {
// ...
}
// Effects
$effect(() => {
// Side effects
@@ -593,46 +619,47 @@ async function fetchFromTandoor<T>(
## Error Handling
### Custom Error Classes
```typescript
// From api/errors.ts
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
export class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}
```
### Try-Catch Pattern
```typescript
export const POST: RequestHandler = async ({ request }) => {
try {
const { url } = await request.json();
if (!url) {
throw new ValidationError('URL is required');
}
const item = queueManager.enqueue(url);
return json(item, { status: 201 });
} catch (error) {
return handleApiError(error);
}
try {
const { url } = await request.json();
if (!url) {
throw new ValidationError('URL is required');
}
const item = queueManager.enqueue(url);
return json(item, { status: 201 });
} catch (error) {
return handleApiError(error);
}
};
```
@@ -641,14 +668,16 @@ export const POST: RequestHandler = async ({ request }) => {
## Linting Configuration
### ESLint
**Config:** `eslint.config.js`
- Base: `@eslint/js` recommended
- TypeScript: `typescript-eslint` recommended
- Svelte: `eslint-plugin-svelte` recommended
- Svelte: `eslint-plugin-svelte` recommended
- Formatting: `eslint-config-prettier`
**Rules:**
```javascript
{
rules: {
@@ -658,15 +687,16 @@ export const POST: RequestHandler = async ({ request }) => {
```
### Prettier
**Config:** `.prettierrc`
```json
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
}
```
@@ -675,38 +705,40 @@ export const POST: RequestHandler = async ({ request }) => {
## Testing Conventions
### Test Structure
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('QueueManager', () => {
let manager: QueueManager;
beforeEach(() => {
manager = new QueueManager();
});
it('should enqueue items', () => {
const item = manager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
});
it('should dequeue items in FIFO order', () => {
manager.enqueue('url1');
manager.enqueue('url2');
const first = manager.dequeue();
expect(first?.url).toBe('url1');
});
let manager: QueueManager;
beforeEach(() => {
manager = new QueueManager();
});
it('should enqueue items', () => {
const item = manager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
});
it('should dequeue items in FIFO order', () => {
manager.enqueue('url1');
manager.enqueue('url2');
const first = manager.dequeue();
expect(first?.url).toBe('url1');
});
});
```
### Mock Pattern
```typescript
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
text: 'Mock text',
thumbnailUrl: 'https://example.com/thumb.jpg'
})
extractTextAndThumbnail: vi.fn().mockResolvedValue({
text: 'Mock text',
thumbnailUrl: 'https://example.com/thumb.jpg'
})
}));
```
@@ -715,14 +747,15 @@ vi.mock('$lib/server/extraction', () => ({
## File Headers
### Module Documentation Pattern
Every major module includes a header comment:
```typescript
/**
* Module Name - Brief Description
*
*
* Detailed description of the module's purpose and functionality.
*
*
* Architecture: Layer Name (Hexagonal Architecture)
* - Port: Description of port interface
* - Implementation: Description of concrete implementation
@@ -730,13 +763,14 @@ Every major module includes a header comment:
```
**Example:**
```typescript
/**
* Queue Manager - Core queue operations and event management
*
*
* Manages an in-memory queue of Instagram URL processing jobs.
* Provides CRUD operations and pub/sub mechanism for queue updates.
*
*
* Architecture: Domain Layer (Hexagonal Architecture)
* - Port: Defines queue operations interface
* - Implementation: In-memory Map-based storage
@@ -748,6 +782,7 @@ Every major module includes a header comment:
## Additional Conventions
### Environment Variables
```typescript
import { env } from '$env/dynamic/private';
@@ -756,32 +791,37 @@ const tandoorUrl = env.TANDOOR_URL || null;
```
### Date Handling
ISO8601 strings throughout the application:
```typescript
const now = new Date().toISOString();
// Output: "2026-02-15T12:30:45.123Z"
```
### Null vs Undefined
- `null`: Intentional absence of value
- `undefined`: Not yet initialized or optional parameters
- Prefer `null` for API responses and data structures
### Async/Await
Always preferred over Promise chains:
```typescript
// Preferred
async function fetchData() {
const response = await fetch(url);
const data = await response.json();
return data;
const response = await fetch(url);
const data = await response.json();
return data;
}
// Avoid
function fetchData() {
return fetch(url)
.then(response => response.json())
.then(data => data);
return fetch(url)
.then((response) => response.json())
.then((data) => data);
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ The migration transformed InstaRecipe from a blocking, synchronous extraction sy
### Architecture Transformation
**Before: Synchronous System**
```
User Request → Direct Processing → Response (wait 30-60s)
↓ ↓ ↓
@@ -18,6 +19,7 @@ User Request → Direct Processing → Response (wait 30-60s)
```
**After: Async Queue System**
```
User Request → Queue Item Created → Immediate Response
↓ ↓ ↓
@@ -60,6 +62,7 @@ User Request → Queue Item Created → Immediate Response
### New Endpoints
#### Queue Management
```typescript
// Enqueue URL for processing
POST /api/queue
@@ -84,6 +87,7 @@ Events: connection, queue-update, ping
```
#### Push Notifications
```typescript
// Subscribe to push notifications
POST /api/notifications/subscribe
@@ -101,11 +105,11 @@ These endpoints are marked for removal and should not be used in new code:
```typescript
// ❌ DEPRECATED: Synchronous extraction
POST /api/extract
POST / api / extract;
// 👉 Use: POST /api/queue
// ❌ DEPRECATED: Long-polling progress
GET /api/extract-stream
GET / api / extract - stream;
// 👉 Use: GET /api/queue/stream
```
@@ -117,33 +121,33 @@ New queue items follow this structure:
```typescript
interface QueueItem {
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
// Processing phases with individual progress
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string;
completedAt?: string;
progress?: number; // 0-100
}>;
// Results (populated on success)
results?: {
recipe?: Recipe; // Extracted recipe data
tandoorUrl?: string; // Link to uploaded recipe
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
// Error information
error?: string;
// Timestamps
createdAt: string;
updatedAt: string;
id: string; // UUID v4
url: string; // Instagram URL
status: 'pending' | 'in_progress' | 'success' | 'error' | 'unhealthy';
// Processing phases with individual progress
phases: Array<{
name: 'extraction' | 'parsing' | 'uploading';
status: 'pending' | 'in_progress' | 'completed' | 'error';
startedAt?: string;
completedAt?: string;
progress?: number; // 0-100
}>;
// Results (populated on success)
results?: {
recipe?: Recipe; // Extracted recipe data
tandoorUrl?: string; // Link to uploaded recipe
extractedText?: string; // Raw extracted text
thumbnail?: string; // Image URL
};
// Error information
error?: string;
// Timestamps
createdAt: string;
updatedAt: string;
}
```
@@ -167,33 +171,35 @@ interface QueueStatusUpdate {
### For Frontend Applications
1. **Replace Synchronous Calls**
```typescript
// ❌ Old synchronous approach
const response = await fetch('/api/extract', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const result = await response.json(); // Wait 30-60 seconds
// ✅ New async queue approach
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url })
method: 'POST',
body: JSON.stringify({ url })
});
const queueItem = await response.json(); // Immediate response
// Navigate to dashboard for real-time updates
window.location.href = `/?highlight=${queueItem.id}`;
```
2. **Add Real-time Updates**
```typescript
// Setup Server-Sent Events for progress tracking
const eventSource = new EventSource(`/api/queue/stream?itemId=${itemId}`);
eventSource.addEventListener('queue-update', (event) => {
const update = JSON.parse(event.data);
updateUI(update);
const update = JSON.parse(event.data);
updateUI(update);
});
```
@@ -201,36 +207,37 @@ interface QueueStatusUpdate {
```typescript
// Handle different queue statuses
switch (item.status) {
case 'pending':
showPendingState();
break;
case 'in_progress':
showProgressBar(item.phases);
break;
case 'success':
showResults(item.results);
break;
case 'error':
showErrorWithRetry(item.error, item.id);
break;
case 'unhealthy':
showRetryableError(item.error, item.id);
break;
case 'pending':
showPendingState();
break;
case 'in_progress':
showProgressBar(item.phases);
break;
case 'success':
showResults(item.results);
break;
case 'error':
showErrorWithRetry(item.error, item.id);
break;
case 'unhealthy':
showRetryableError(item.error, item.id);
break;
}
```
### For Backend Integrations
1. **Update API Calls**
```python
# ❌ Old synchronous API
response = requests.post('/api/extract', json={'url': url})
# This would block for 30-60 seconds
# ✅ New async queue API
response = requests.post('/api/queue', json={'url': url})
queue_item = response.json()
# Poll or use SSE for updates
while True:
item = requests.get(f'/api/queue/{queue_item["id"]}').json()
@@ -240,9 +247,10 @@ interface QueueStatusUpdate {
```
2. **Implement SSE Client** (Python example)
```python
import sseclient
def listen_to_queue_updates(item_id):
messages = sseclient.SSEClient(f'/api/queue/stream?itemId={item_id}')
for msg in messages:
@@ -266,7 +274,7 @@ QUEUE_TIMEOUT_MS=30000 # Processing timeout
QUEUE_RETRY_ATTEMPTS=3 # Maximum retry attempts
# Push notification settings (optional)
VAPID_PUBLIC_KEY=BDummyPublicKey...
VAPID_PUBLIC_KEY=BDummyPublicKey...
VAPID_PRIVATE_KEY=DummyPrivateKey...
# Existing LLM and Tandoor settings remain the same
@@ -306,7 +314,7 @@ npm test
# Test specific components
npm test queue-manager
npm test queue-processor
npm test queue-processor
npm test queue-api
npm test queue-sse
```
@@ -314,18 +322,21 @@ npm test queue-sse
## Performance Considerations
### Before Migration
- **Blocking Operations**: Each request blocked a server thread
- **Single Processing**: One extraction at a time
- **No Progress**: Users waited without feedback
- **Memory Usage**: High memory usage during long operations
### After Migration
### After Migration
- **Non-blocking**: Requests return immediately
- **Concurrent Processing**: Multiple extractions in parallel
- **Real-time Feedback**: Live progress updates
- **Efficient Memory**: Event-driven, minimal memory footprint
### Performance Metrics
- **Response Time**: 50ms (queue) vs 30-60s (synchronous)
- **Throughput**: 2x concurrent processing vs 1x sequential
- **User Experience**: Immediate feedback vs long waiting
@@ -336,11 +347,13 @@ npm test queue-sse
If issues arise, the system can be rolled back by:
1. **Disable Queue Processing**
```env
QUEUE_PROCESSING_ENABLED=false
```
2. **Re-enable Legacy Endpoints** (if preserved)
```typescript
// Temporary fallback to synchronous processing
app.post('/api/extract', legacyExtractHandler);
@@ -389,10 +402,10 @@ curl -X POST https://localhost:5173/api/notifications/vapid-key
The migration to an async queue system represents a significant architectural improvement that provides:
- **Better User Experience**: Immediate responses and real-time progress
- **Improved Reliability**: Error recovery and retry mechanisms
- **Improved Reliability**: Error recovery and retry mechanisms
- **Enhanced Performance**: Concurrent processing and resource efficiency
- **Modern Features**: Push notifications and PWA capabilities
The new system maintains backward compatibility during the transition period while providing a clear migration path for all integrations.
For questions or issues during migration, refer to the comprehensive test suite and documentation, or open an issue in the project repository.
For questions or issues during migration, refer to the comprehensive test suite and documentation, or open an issue in the project repository.

View File

@@ -3,6 +3,7 @@
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
## Table of Contents
- [Core Principle](#core-principle)
- [Browser API Detection](#browser-api-detection)
- [Lifecycle Hooks](#lifecycle-hooks)
@@ -18,6 +19,7 @@ This guide documents SSR-safe patterns and anti-patterns for InstaRecipe develop
**SvelteKit renders components on both the server and client.** Any browser-only APIs must be guarded or used in browser-only contexts.
### Browser-Only APIs (Require Guards)
- `window.*`
- `document.*`
- `localStorage`, `sessionStorage`
@@ -36,8 +38,8 @@ This guide documents SSR-safe patterns and anti-patterns for InstaRecipe develop
import { browser } from '$app/environment';
if (browser) {
// Safe: only runs in browser
const data = localStorage.getItem('key');
// Safe: only runs in browser
const data = localStorage.getItem('key');
}
```
@@ -49,14 +51,14 @@ if (browser) {
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
function startSSEConnection() {
if (!browser) return; // ✅ Guard
eventSource = new EventSource('/api/stream');
}
onMount(() => {
if (browser) { // ✅ Explicit guard
startSSEConnection();
@@ -72,6 +74,7 @@ if (browser) {
### `onMount` - Browser-Only Lifecycle
**Use `onMount` for:**
- Browser API initialization
- Timer setup (`setInterval`, `setTimeout`)
- Event listener registration
@@ -81,12 +84,12 @@ if (browser) {
import { onMount } from 'svelte';
onMount(() => {
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
return () => clearInterval(interval); // Cleanup
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
return () => clearInterval(interval); // Cleanup
});
```
@@ -96,8 +99,8 @@ onMount(() => {
import { onDestroy } from 'svelte';
onDestroy(() => {
// ✅ Safe for cleanup
eventSource?.close();
// ✅ Safe for cleanup
eventSource?.close();
});
```
@@ -117,7 +120,7 @@ let stored = $state(localStorage.getItem('key')); // SSR crash!
// ✅ DO: Load in onMount
let stored = $state<string | null>(null);
onMount(() => {
stored = localStorage.getItem('key');
stored = localStorage.getItem('key');
});
```
@@ -142,29 +145,31 @@ let userAgent = $derived(navigator.userAgent); // SSR crash!
```typescript
// ❌ BAD: No browser guard
$effect(() => {
setInterval(() => checkHealth(), 1000); // SSR crash!
setInterval(() => checkHealth(), 1000); // SSR crash!
});
// ✅ GOOD: With browser guard
$effect(() => {
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
// ✅ BETTER: Use onMount for initialization instead
onMount(() => {
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
```
**When to use `$effect`:**
- Synchronizing derived state
- DOM manipulation (with browser guard)
- Reactive cleanup
**When NOT to use `$effect`:**
- Initialization (use `onMount`)
- API calls on mount (use `onMount`)
- Timer setup (use `onMount`)
@@ -189,11 +194,13 @@ if (browser && eventSource?.readyState === EventSource.OPEN)
```
**EventSource States:**
- `EventSource.CONNECTING = 0`
- `EventSource.OPEN = 1`
- `EventSource.CLOSED = 2`
**WebSocket States:**
- `WebSocket.CONNECTING = 0`
- `WebSocket.OPEN = 1`
- `WebSocket.CLOSING = 2`
@@ -220,8 +227,8 @@ const interval = setInterval(() => {}, 1000); // SSR crash!
// ✅ GOOD: In onMount
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
@@ -260,22 +267,23 @@ onMount(() => {
import { browser } from '$app/environment';
export class PushNotificationManager {
private static instance: PushNotificationManager | null = null;
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
private static instance: PushNotificationManager | null = null;
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
}
```
**Why it's good:**
- Guards all browser API access
- Early returns prevent unnecessary code execution during SSR
- Defensive programming with null checks
@@ -288,16 +296,16 @@ export class PushNotificationManager {
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
onMount(async () => {
await loadQueueItems();
if (browser) { // ✅ Guard
startSSEConnection();
}
});
function startSSEConnection() {
if (!browser) return; // ✅ Double guard for safety
eventSource = new EventSource('/api/queue/stream');
@@ -316,7 +324,7 @@ export class PushNotificationManager {
```svelte
<script lang="ts">
import { onMount } from 'svelte';
onMount(() => {
// ✅ onMount only runs in browser
checkHealth(); // Initial check
@@ -327,6 +335,7 @@ export class PushNotificationManager {
```
**Why it's good:**
- Uses `onMount` instead of `$effect` for initialization
- Timer setup in browser-only context
- Proper cleanup with return function
@@ -344,7 +353,7 @@ let theme = $derived(localStorage.getItem('theme'));
// ✅ DO
let theme = $state<string | null>(null);
onMount(() => {
theme = localStorage.getItem('theme');
theme = localStorage.getItem('theme');
});
```
@@ -353,19 +362,19 @@ onMount(() => {
```typescript
// ❌ DON'T
$effect(() => {
// Runs during SSR!
fetch('/api/data');
// Runs during SSR!
fetch('/api/data');
});
// ✅ DO: Guard browser-specific side effects
$effect(() => {
if (!browser) return;
fetch('/api/data');
if (!browser) return;
fetch('/api/data');
});
// ✅ BETTER: Use onMount for initialization
onMount(() => {
fetch('/api/data');
fetch('/api/data');
});
```
@@ -387,8 +396,8 @@ const interval = setInterval(() => {}, 1000);
// ✅ DO
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
```
@@ -408,6 +417,7 @@ Watch server console for errors during build and preview.
### 2. Check for Hydration Warnings
Open browser DevTools console and look for:
- "Hydration failed"
- "The server response doesn't match the client content"
@@ -422,6 +432,7 @@ grep -r "navigator\." src/routes --include="*.svelte"
```
Then verify each usage is either:
- In an event handler (safe)
- In `onMount` (safe)
- Guarded with `if (browser)` (safe)

View File

@@ -7,7 +7,7 @@ This guide explains how to properly mock dependencies when testing SvelteKit app
SvelteKit has a unique architecture where code can run on both server and client. This affects how we mock:
1. **Server-only modules** (`$lib/server/*`, `*.server.ts`) - Only run on server
2. **Universal modules** - Can run on both server and client
2. **Universal modules** - Can run on both server and client
3. **Environment variables** - Different modules for static vs dynamic access
## Key Principles
@@ -32,12 +32,12 @@ SvelteKit has a unique architecture where code can run on both server and client
import { env } from '$env/dynamic/private';
export const queueConfig = {
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
token: env.TANDOOR_TOKEN || null
}
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
token: env.TANDOOR_TOKEN || null
}
};
```
@@ -49,21 +49,21 @@ import * as queueConfigModule from '$lib/server/queue/config';
// Mock the config module
vi.mock('$lib/server/queue/config', () => ({
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: { enabled: true, token: 'test-token' }
}
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: { enabled: true, token: 'test-token' }
}
}));
describe('QueueProcessor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
});
```
@@ -78,10 +78,10 @@ import { vi } from 'vitest';
// IMPORTANT: Mock BEFORE importing the module that uses it
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Mock recipe text',
thumbnail: 'https://mock.com/image.jpg'
})
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Mock recipe text',
thumbnail: 'https://mock.com/image.jpg'
})
}));
// NOW import the module that depends on these
@@ -89,15 +89,15 @@ import { queueProcessor } from '$lib/server/queue/QueueProcessor';
import { extractTextAndThumbnail } from '$lib/server/extraction';
describe('QueueProcessor', () => {
it('should use mocked services', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
// Verify mock was called
expect(extractTextAndThumbnail).toHaveBeenCalledWith(
'https://instagram.com/p/test',
expect.any(Function)
);
});
it('should use mocked services', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
// Verify mock was called
expect(extractTextAndThumbnail).toHaveBeenCalledWith(
'https://instagram.com/p/test',
expect.any(Function)
);
});
});
```
@@ -112,22 +112,22 @@ import { describe, it, expect } from 'vitest';
import { POST } from '../routes/api/queue/+server';
describe('POST /api/queue', () => {
it('should reject invalid URLs', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'invalid-url' })
});
const response = await POST({ request } as any);
// ✅ CORRECT - Check status first
expect(response.status).toBe(400);
// ✅ CORRECT - Properly await error response
const data = await response.json();
expect(data.message).toContain('Invalid');
});
it('should reject invalid URLs', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'invalid-url' })
});
const response = await POST({ request } as any);
// ✅ CORRECT - Check status first
expect(response.status).toBe(400);
// ✅ CORRECT - Properly await error response
const data = await response.json();
expect(data.message).toContain('Invalid');
});
});
```
@@ -136,17 +136,17 @@ describe('POST /api/queue', () => {
```typescript
// ❌ WRONG - This will fail
it('should reject invalid input', async () => {
const response = await endpoint({ request } as any);
const data = response.json(); // Missing await!
expect(data.message).toBe('Error'); // data is a Promise
const response = await endpoint({ request } as any);
const data = response.json(); // Missing await!
expect(data.message).toBe('Error'); // data is a Promise
});
// ✅ CORRECT
it('should reject invalid input', async () => {
const response = await endpoint({ request } as any);
expect(response.status).toBe(400);
const data = await response.json(); // Properly awaited
expect(data.message).toBe('Error');
const response = await endpoint({ request } as any);
expect(response.status).toBe(400);
const data = await response.json(); // Properly awaited
expect(data.message).toBe('Error');
});
```
@@ -173,11 +173,11 @@ import { queueProcessor } from './QueueProcessor';
import { beforeEach, afterEach } from 'vitest';
beforeEach(() => {
vi.clearAllMocks(); // Clear call history
vi.clearAllMocks(); // Clear call history
});
afterEach(() => {
vi.restoreAllMocks(); // Restore original implementations
vi.restoreAllMocks(); // Restore original implementations
});
```
@@ -203,16 +203,16 @@ const mockFn = vi.fn() as Mock<() => Promise<string>>;
```typescript
it('should process item', async () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
// Wait for processing with timeout
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('success');
},
{ timeout: 5000, interval: 100 }
);
const item = queueManager.enqueue('https://instagram.com/p/test');
// Wait for processing with timeout
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('success');
},
{ timeout: 5000, interval: 100 }
);
});
```
@@ -222,20 +222,20 @@ it('should process item', async () => {
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.useRealTimers();
});
it('should process after delay', async () => {
queueManager.enqueue('https://test.com');
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000);
// Now check results
queueManager.enqueue('https://test.com');
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000);
// Now check results
});
```
@@ -263,7 +263,7 @@ vi.mock('./module', () => ({ export: vi.fn() }));
// Mock with factory
vi.mock('./module', () => {
return { dynamicExport: () => 'value' };
return { dynamicExport: () => 'value' };
});
// Spy on existing export
@@ -285,9 +285,9 @@ expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg');
// Reset/restore
vi.clearAllMocks(); // Clear call history
vi.resetAllMocks(); // + Reset implementations
vi.restoreAllMocks(); // + Restore original implementations
vi.clearAllMocks(); // Clear call history
vi.resetAllMocks(); // + Reset implementations
vi.restoreAllMocks(); // + Restore original implementations
// Environment variables
vi.stubEnv('VAR_NAME', 'value');

File diff suppressed because it is too large Load Diff

View File

@@ -21,16 +21,14 @@ export default defineConfig(
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": 'off' }
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: [
'**/*.svelte',
'**/*.svelte.ts',
'**/*.svelte.js'
],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,

View File

@@ -1,63 +1,63 @@
{
"name": "insta-recipe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:host": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"fast-glob": "^3.3.3",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^6.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"date-fns": "^4.1.0",
"openai": "^4.20.0",
"playwright": "^1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^3.23.0"
},
"overrides": {
"cookie": "^0.7.0",
"ajv": "^8.18.0"
}
"name": "insta-recipe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:host": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^22",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"fast-glob": "^3.3.3",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^6.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"date-fns": "^4.1.0",
"openai": "^4.20.0",
"playwright": "^1.56.1",
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^3.23.0"
},
"overrides": {
"cookie": "^0.7.0",
"ajv": "^8.18.0"
}
}

View File

@@ -2,33 +2,33 @@ import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for E2E tests
*
*
* See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testMatch: '**/*.e2e.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
testDir: './src/tests',
testMatch: '**/*.e2e.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});

View File

@@ -4,23 +4,23 @@ import fs from 'fs';
import path from 'path';
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
console.log('🔹 Navigating to Instagram...');
await page.goto('https://www.instagram.com/');
console.log('⏳ Please log in manually. Waiting for "Home" icon...');
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('../secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
await context.storageState({ path: path.join(secretsDir, 'auth.json') });
console.log('🎉 Session saved to secrets/auth.json');
} catch (e) {
console.error('❌ Timeout or error:', e);
}
await browser.close();
})();
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
console.log('🔹 Navigating to Instagram...');
await page.goto('https://www.instagram.com/');
console.log('⏳ Please log in manually. Waiting for "Home" icon...');
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('../secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
await context.storageState({ path: path.join(secretsDir, 'auth.json') });
console.log('🎉 Session saved to secrets/auth.json');
} catch (e) {
console.error('❌ Timeout or error:', e);
}
await browser.close();
})();

View File

@@ -7,50 +7,50 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateFaviconIco() {
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.ico');
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.ico');
console.log('Generating favicon.ico from icon-source.png...');
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
console.log('Generating favicon.ico from icon-source.png...');
// Resize to 32x32 with transparent background
await sharp(sourceIcon)
.resize(32, 32, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Resize to 32x32 with transparent background
await sharp(sourceIcon)
.resize(32, 32, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
console.log(`✓ favicon.ico generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Validate success criteria
if (metadata.width !== 32 || metadata.height !== 32) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
console.log(`✓ favicon.ico generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
console.log('✓ All validation checks passed');
// Validate success criteria
if (metadata.width !== 32 || metadata.height !== 32) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
console.log('✓ All validation checks passed');
}
generateFaviconIco().catch(err => {
console.error('Error generating favicon.ico:', err);
process.exit(1);
generateFaviconIco().catch((err) => {
console.error('Error generating favicon.ico:', err);
process.exit(1);
});

View File

@@ -7,54 +7,54 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function generateFavicon() {
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.png');
const sourceIcon = path.join(__dirname, '..', 'static', 'icon-source.png');
const outputIcon = path.join(__dirname, '..', 'static', 'favicon.png');
console.log('Generating favicon.png from icon-source.png...');
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
console.log('Generating favicon.png from icon-source.png...');
// Resize to 192x192 with transparent background
await sharp(sourceIcon)
.resize(192, 192, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
// Verify source file exists
if (!fs.existsSync(sourceIcon)) {
console.error('Error: icon-source.png not found at', sourceIcon);
process.exit(1);
}
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Resize to 192x192 with transparent background
await sharp(sourceIcon)
.resize(192, 192, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.ensureAlpha()
.png()
.toFile(outputIcon);
console.log(`✓ favicon.png generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
// Verify output file
const metadata = await sharp(outputIcon).metadata();
const stats = fs.statSync(outputIcon);
// Validate success criteria
if (metadata.width !== 192 || metadata.height !== 192) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 100 * 1024) {
console.error('Error: File size exceeds 100KB');
process.exit(1);
}
console.log(`✓ favicon.png generated successfully`);
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${(stats.size / 1024).toFixed(1)}KB`);
console.log('✓ All validation checks passed');
// Validate success criteria
if (metadata.width !== 192 || metadata.height !== 192) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 100 * 1024) {
console.error('Error: File size exceeds 100KB');
process.exit(1);
}
console.log('✓ All validation checks passed');
}
generateFavicon().catch(err => {
console.error('Error generating favicon:', err);
process.exit(1);
generateFavicon().catch((err) => {
console.error('Error generating favicon:', err);
process.exit(1);
});

View File

@@ -2,54 +2,54 @@ const sharp = require('sharp');
const fs = require('fs');
async function generateIcon512() {
try {
console.log('Generating icon-512.png from icon-source.png...');
// Check if source file exists
if (!fs.existsSync('static/icon-source.png')) {
console.error('Error: static/icon-source.png does not exist');
process.exit(1);
}
// Generate 512x512 icon
await sharp('static/icon-source.png')
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile('static/icon-512.png');
console.log('✓ Generated static/icon-512.png');
// Verify the result
const metadata = await sharp('static/icon-512.png').metadata();
const stats = fs.statSync('static/icon-512.png');
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${Math.round(stats.size / 1024)}KB`);
// Validate
if (metadata.width !== 512 || metadata.height !== 512) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 200 * 1024) {
console.error('Error: File size exceeds 200KB');
process.exit(1);
}
console.log('✓ Validation passed');
process.exit(0);
} catch (error) {
console.error('Error generating icon:', error.message);
process.exit(1);
}
try {
console.log('Generating icon-512.png from icon-source.png...');
// Check if source file exists
if (!fs.existsSync('static/icon-source.png')) {
console.error('Error: static/icon-source.png does not exist');
process.exit(1);
}
// Generate 512x512 icon
await sharp('static/icon-source.png')
.resize(512, 512, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toFile('static/icon-512.png');
console.log('✓ Generated static/icon-512.png');
// Verify the result
const metadata = await sharp('static/icon-512.png').metadata();
const stats = fs.statSync('static/icon-512.png');
console.log(` Dimensions: ${metadata.width}x${metadata.height}`);
console.log(` Format: ${metadata.format}`);
console.log(` Size: ${Math.round(stats.size / 1024)}KB`);
// Validate
if (metadata.width !== 512 || metadata.height !== 512) {
console.error('Error: Invalid dimensions');
process.exit(1);
}
if (metadata.format !== 'png') {
console.error('Error: Invalid format');
process.exit(1);
}
if (stats.size > 200 * 1024) {
console.error('Error: File size exceeds 200KB');
process.exit(1);
}
console.log('✓ Validation passed');
process.exit(0);
} catch (error) {
console.error('Error generating icon:', error.message);
process.exit(1);
}
}
generateIcon512();

View File

@@ -1,135 +1,135 @@
{
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805933297.800746,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525143,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
"domain": ".instagram.com",
"path": "/",
"expires": 1796208680.653147,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525191,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1779149297.800838,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1797910987.674116,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "wd",
"value": "1280x720",
"domain": ".instagram.com",
"path": "/",
"expires": 1771978099,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802909297:01fe3b61178e8a40cb655e8d1aa137841487d29a8caf122f877ce2955e92fb4307b53ced\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "9b4abdd3-f38f-473e-9e3b-2ae722acc64d"
},
{
"name": "hb_timestamp",
"value": "1771370599886"
},
{
"name": "IGSession",
"value": "k75336:1771375099770"
},
{
"name": "mutex_polaris_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771371499888"
},
{
"name": "Session",
"value": "t5cu8b:1771373334770"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "mutex_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
}
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805933297.800746,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525143,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
"domain": ".instagram.com",
"path": "/",
"expires": 1796208680.653147,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525191,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1779149297.800838,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1797910987.674116,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "wd",
"value": "1280x720",
"domain": ".instagram.com",
"path": "/",
"expires": 1771978099,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802909297:01fe3b61178e8a40cb655e8d1aa137841487d29a8caf122f877ce2955e92fb4307b53ced\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "9b4abdd3-f38f-473e-9e3b-2ae722acc64d"
},
{
"name": "hb_timestamp",
"value": "1771370599886"
},
{
"name": "IGSession",
"value": "k75336:1771375099770"
},
{
"name": "mutex_polaris_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771371499888"
},
{
"name": "Session",
"value": "t5cu8b:1771373334770"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "mutex_banzai",
"value": "4eic7h:1771373300769"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="/manifest.json" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -1,27 +1,27 @@
import { initializeBrowser, closeBrowser } from '$lib/server/browser';
// Initialize browser when server starts
export async function init() {
try {
await initializeBrowser();
} catch (error) {
console.error('Failed to initialize browser:', error);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
}
// Run initialization immediately
init().catch(console.error);
import { initializeBrowser, closeBrowser } from '$lib/server/browser';
// Initialize browser when server starts
export async function init() {
try {
await initializeBrowser();
} catch (error) {
console.error('Failed to initialize browser:', error);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
}
// Run initialization immediately
init().catch(console.error);

View File

@@ -1,32 +1,32 @@
import { startScheduler, stopScheduler } from '$lib/server/scheduler';
import '$lib/server/queue/QueueProcessor'; // Trigger QueueProcessor auto-start
import type { ServerInit } from '@sveltejs/kit';
/**
* Initialize server-wide functionality
* Runs once when the server starts
*
* Environment variables:
* - AUTH_SCHEDULER_ENABLED: Set to 'true' to enable periodic auth renewal
* - AUTH_SCHEDULER_INTERVAL_MINUTES: Minutes between each renewal (default: 720)
*/
export const init: ServerInit = async () => {
console.log('[Server Init] Starting SvelteKit server...');
console.log('[Server Init] QueueProcessor auto-started via import');
// The scheduler will renew the Instagram session by loading the existing auth.json
// and refreshing it with Instagram (requires initial setup via gen-auth.js)
await startScheduler();
};
/**
* Listen for graceful shutdown
* Clean up resources when the server is shutting down
*/
process.on('sveltekit:shutdown', async (reason) => {
console.log(`[Server Shutdown] Shutdown triggered by: ${reason}`);
// Stop the scheduler gracefully
await stopScheduler();
console.log('[Server Shutdown] Cleanup complete');
});
import { startScheduler, stopScheduler } from '$lib/server/scheduler';
import '$lib/server/queue/QueueProcessor'; // Trigger QueueProcessor auto-start
import type { ServerInit } from '@sveltejs/kit';
/**
* Initialize server-wide functionality
* Runs once when the server starts
*
* Environment variables:
* - AUTH_SCHEDULER_ENABLED: Set to 'true' to enable periodic auth renewal
* - AUTH_SCHEDULER_INTERVAL_MINUTES: Minutes between each renewal (default: 720)
*/
export const init: ServerInit = async () => {
console.log('[Server Init] Starting SvelteKit server...');
console.log('[Server Init] QueueProcessor auto-started via import');
// The scheduler will renew the Instagram session by loading the existing auth.json
// and refreshing it with Instagram (requires initial setup via gen-auth.js)
await startScheduler();
};
/**
* Listen for graceful shutdown
* Clean up resources when the server is shutting down
*/
process.on('sveltekit:shutdown', async (reason) => {
console.log(`[Server Shutdown] Shutdown triggered by: ${reason}`);
// Stop the scheduler gracefully
await stopScheduler();
console.log('[Server Shutdown] Cleanup complete');
});

View File

@@ -1,6 +1,6 @@
/**
* PWA Installation Manager
*
*
* Handles PWA installation flow with cross-browser support.
* Provides beforeinstallprompt event handling, user engagement detection,
* and dismissal state management for the install prompt.
@@ -9,193 +9,193 @@
import { browser } from '$app/environment';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export class PWAInstallManager {
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Clear dismissal state since user installed
this.clearDismissed();
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
// Clear dismissal state since user installed
this.clearDismissed();
});
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
// Call immediately with current state
callback(this.canInstall());
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach(callback => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
// Call immediately with current state
callback(this.canInstall());
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach((callback) => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
}
// Singleton instance for application-wide use
export const pwaInstallManager = new PWAInstallManager();
export const pwaInstallManager = new PWAInstallManager();

View File

@@ -1,379 +1,371 @@
/**
* Client-side Push Notification Manager
*
*
* Handles push notification subscription/unsubscription
* and permission management in the browser.
*
*
* SSR-Safe: All browser API access is guarded and lazily initialized
*/
import { browser } from '$app/environment';
interface NotificationState {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
}
class PushNotificationManager {
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
this.listeners.push(callback);
callback(this.state); // Send initial state
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
this.state.supported = (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
this.listeners.push(callback);
callback(this.state); // Send initial state
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
if (this.state.permission === 'granted') {
return true;
}
this.state.supported =
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
try {
this.state.loading = true;
this.notifyListeners();
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
this.state.loading = false;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!await this.requestPermission()) {
return false;
}
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
const { publicKey } = await vapidResponse.json();
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
if (this.state.permission === 'granted') {
return true;
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
try {
this.state.loading = true;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
this.state.loading = false;
this.notifyListeners();
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!(await this.requestPermission())) {
return false;
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
const { publicKey } = await vapidResponse.json();
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
try {
// Add proper padding
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
const base64 = (cleanKey + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach(callback => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
try {
// Add proper padding
const padding = '='.repeat((4 - (cleanKey.length % 4)) % 4);
const base64 = (cleanKey + padding).replace(/-/g, '+').replace(/_/g, '/');
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach((callback) => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
}
// Singleton instance
export const pushNotificationManager = new PushNotificationManager();
export type { NotificationState };
export type { NotificationState };

View File

@@ -1,201 +1,201 @@
/**
* Service Worker Message Handler
*
*
* Handles messages from service worker (like notification actions)
* and coordinates with the main application.
*/
import { pushState } from "$app/navigation";
import { pushState } from '$app/navigation';
interface ServiceWorkerMessage {
type: string;
action?: string;
data?: any;
type: string;
action?: string;
data?: any;
}
class ServiceWorkerMessageHandler {
private retryCallbacks = new Map<string, () => void>();
private retryCallbacks = new Map<string, () => void>();
constructor() {
this.initializeMessageListener();
}
constructor() {
this.initializeMessageListener();
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
case 'retry':
this.handleRetryAction(data.itemId);
break;
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
// Refresh page to show the item
//window.location.reload();
}
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
case 'retry':
this.handleRetryAction(data.itemId);
break;
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
// Refresh page to show the item
//window.location.reload();
}
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
registration.active?.postMessage(message, [channel.port2]);
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
registration.active?.postMessage(message, [channel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
}
// Singleton instance
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();

View File

@@ -1,15 +1,15 @@
/**
* API Error Handler
*
*
* Centralizes error handling for API endpoints by converting
* application errors into appropriate HTTP responses.
*
*
* Maps error types to status codes:
* - ValidationError → 400 Bad Request
* - NotFoundError → 404 Not Found
* - ConflictError → 409 Conflict
* - Other errors → 500 Internal Server Error
*
*
* Provides consistent error response format across all API endpoints.
*/
@@ -19,46 +19,56 @@ import { logError } from '../utils/logger';
/**
* Handle API errors and convert to appropriate HTTP responses
*
*
* @param error - Error to handle (can be any type)
* @returns JSON response with appropriate status code and error message
*/
export function handleApiError(error: unknown): Response {
// Log all errors for debugging
logError('[API Error]', error);
// Log all errors for debugging
logError('[API Error]', error);
// Handle known error types with specific status codes
if (error instanceof ValidationError) {
return json({
message: error.message,
type: 'validation_error'
}, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({
message: error.message,
type: 'not_found_error'
}, { status: 404 });
}
if (error instanceof ConflictError) {
return json({
message: error.message,
type: 'conflict_error'
}, { status: 409 });
}
// Handle known error types with specific status codes
if (error instanceof ValidationError) {
return json(
{
message: error.message,
type: 'validation_error'
},
{ status: 400 }
);
}
// Handle generic errors
const message = error instanceof Error ? error.message : 'Unknown error occurred';
// Don't expose internal error details in production
const publicMessage = process.env.NODE_ENV === 'production'
? 'Internal server error'
: message;
if (error instanceof NotFoundError) {
return json(
{
message: error.message,
type: 'not_found_error'
},
{ status: 404 }
);
}
return json({
message: publicMessage,
type: 'server_error'
}, { status: 500 });
}
if (error instanceof ConflictError) {
return json(
{
message: error.message,
type: 'conflict_error'
},
{ status: 409 }
);
}
// Handle generic errors
const message = error instanceof Error ? error.message : 'Unknown error occurred';
// Don't expose internal error details in production
const publicMessage = process.env.NODE_ENV === 'production' ? 'Internal server error' : message;
return json(
{
message: publicMessage,
type: 'server_error'
},
{ status: 500 }
);
}

View File

@@ -1,11 +1,11 @@
/**
* Custom Error Classes for API Error Handling
*
*
* Defines specific error types that map to HTTP status codes:
* - ValidationError → 400 Bad Request
* - NotFoundError → 404 Not Found
* - NotFoundError → 404 Not Found
* - ConflictError → 409 Conflict
*
*
* Used by API endpoints to throw meaningful errors that are
* caught and converted to proper HTTP responses by errorHandler.ts
*/
@@ -15,10 +15,10 @@
* Thrown when request data is invalid or malformed
*/
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
/**
@@ -26,10 +26,10 @@ export class ValidationError extends Error {
* Thrown when requested resource does not exist
*/
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
/**
@@ -37,8 +37,8 @@ export class NotFoundError extends Error {
* Thrown when operation conflicts with current resource state
*/
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}

View File

@@ -1,120 +1,120 @@
import { chromium } from 'playwright-extra';
import type { Browser, BrowserContext } from 'playwright';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import fs from 'fs';
// Apply stealth plugin with all evasion techniques
chromium.use(StealthPlugin());
let browser: Browser | null = null;
interface BrowserOptions {
userAgent?: string;
viewport?: { width: number; height: number };
locale?: string;
timezone?: string;
}
export async function initializeBrowser(): Promise<Browser> {
if (browser) {
return browser;
}
console.log('Initializing Playwright browser...');
// Use environment variable or let Playwright use its bundled browser
const executablePath = process.env.CHROMIUM_EXECUTABLE_PATH || '/usr/bin/google-chrome';
const launchOptions: Parameters<typeof chromium.launch>[0] = {
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu'
]
};
// In test environment, let Playwright use bundled browser
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
launchOptions.executablePath = executablePath;
}
browser = await chromium.launch(launchOptions);
console.log('Browser initialized successfully');
return browser;
}
export async function getBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
if (browser) {
console.warn('Browser is disconnected. Re-initializing...');
try {
await browser.close();
} catch (e) {
/* ignore */
}
browser = null;
}
return initializeBrowser();
}
return browser;
}
export async function createBrowserContext(
authStoragePath?: string,
options?: BrowserOptions
): Promise<BrowserContext> {
const browserInstance = await getBrowser();
// Default stealth options
const defaultOptions: BrowserOptions = {
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1080, height: 1920 },
locale: 'en-US',
timezone: 'America/New_York'
};
const finalOptions = { ...defaultOptions, ...options };
// Load auth if available
let context: BrowserContext;
const contextOptions = {
storageState: authStoragePath && fs.existsSync(authStoragePath) ? authStoragePath : undefined,
userAgent: finalOptions.userAgent,
viewport: finalOptions.viewport,
locale: finalOptions.locale,
timezoneId: finalOptions.timezone,
permissions: [],
colorScheme: 'light' as const
};
if (authStoragePath && fs.existsSync(authStoragePath)) {
console.log('Loading authentication from:', authStoragePath);
} else {
console.warn('No auth storage found. Running as guest.');
}
context = await browserInstance.newContext(contextOptions);
// Note: Anti-detection scripts are now handled automatically by the stealth plugin
// The plugin applies 15+ evasion techniques including:
// - navigator.webdriver masking
// - chrome.runtime mocking
// - User-Agent override
// - WebGL fingerprinting evasion
// - And many more...
return context;
}
export async function closeBrowser(): Promise<void> {
if (browser) {
console.log('Closing Playwright browser...');
await browser.close();
browser = null;
}
}
import { chromium } from 'playwright-extra';
import type { Browser, BrowserContext } from 'playwright';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import fs from 'fs';
// Apply stealth plugin with all evasion techniques
chromium.use(StealthPlugin());
let browser: Browser | null = null;
interface BrowserOptions {
userAgent?: string;
viewport?: { width: number; height: number };
locale?: string;
timezone?: string;
}
export async function initializeBrowser(): Promise<Browser> {
if (browser) {
return browser;
}
console.log('Initializing Playwright browser...');
// Use environment variable or let Playwright use its bundled browser
const executablePath = process.env.CHROMIUM_EXECUTABLE_PATH || '/usr/bin/google-chrome';
const launchOptions: Parameters<typeof chromium.launch>[0] = {
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu'
]
};
// In test environment, let Playwright use bundled browser
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
launchOptions.executablePath = executablePath;
}
browser = await chromium.launch(launchOptions);
console.log('Browser initialized successfully');
return browser;
}
export async function getBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
if (browser) {
console.warn('Browser is disconnected. Re-initializing...');
try {
await browser.close();
} catch (e) {
/* ignore */
}
browser = null;
}
return initializeBrowser();
}
return browser;
}
export async function createBrowserContext(
authStoragePath?: string,
options?: BrowserOptions
): Promise<BrowserContext> {
const browserInstance = await getBrowser();
// Default stealth options
const defaultOptions: BrowserOptions = {
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1080, height: 1920 },
locale: 'en-US',
timezone: 'America/New_York'
};
const finalOptions = { ...defaultOptions, ...options };
// Load auth if available
let context: BrowserContext;
const contextOptions = {
storageState: authStoragePath && fs.existsSync(authStoragePath) ? authStoragePath : undefined,
userAgent: finalOptions.userAgent,
viewport: finalOptions.viewport,
locale: finalOptions.locale,
timezoneId: finalOptions.timezone,
permissions: [],
colorScheme: 'light' as const
};
if (authStoragePath && fs.existsSync(authStoragePath)) {
console.log('Loading authentication from:', authStoragePath);
} else {
console.warn('No auth storage found. Running as guest.');
}
context = await browserInstance.newContext(contextOptions);
// Note: Anti-detection scripts are now handled automatically by the stealth plugin
// The plugin applies 15+ evasion techniques including:
// - navigator.webdriver masking
// - chrome.runtime mocking
// - User-Agent override
// - WebGL fingerprinting evasion
// - And many more...
return context;
}
export async function closeBrowser(): Promise<void> {
if (browser) {
console.log('Closing Playwright browser...');
await browser.close();
browser = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -56,9 +56,9 @@ export async function checkModelAvailability(
const { client } = createLLM();
const response = await client.models.list();
const models = response.data || [];
const foundModel = models.find((m) => m.id === model);
if (foundModel) {
console.log('[LLM] Model available:', model);
return { available: true };
@@ -78,4 +78,4 @@ export async function checkModelAvailability(
message: `Failed to check model availability: ${(e as Error).message}`
};
}
}
}

View File

@@ -1,6 +1,6 @@
/**
* Push Notification Service for InstaRecipe Queue System
*
*
* Handles web push notifications for background processing updates
* when users are not actively viewing the application.
*/
@@ -10,233 +10,237 @@ import webpush from 'web-push';
import { queueConfig } from '../queue/config';
interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
interface NotificationPayload {
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tag?: string;
requireInteraction?: boolean;
analytics?: any;
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tag?: string;
requireInteraction?: boolean;
analytics?: any;
}
class PushNotificationService {
private subscriptions = new Map<string, PushSubscription>();
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
private subscriptions = new Map<string, PushSubscription>();
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
constructor() {
this.loadVapidKeys();
// Configure web-push with VAPID details
if (this.vapidKeys) {
webpush.setVapidDetails(
queueConfig.push.vapidEmail,
this.vapidKeys.publicKey,
this.vapidKeys.privateKey
);
}
}
constructor() {
this.loadVapidKeys();
/**
* Load VAPID keys for push notifications
* In production, these should be stored securely and loaded from environment
*/
private loadVapidKeys() {
// Load from config module which uses SvelteKit's $env/dynamic/private
this.vapidKeys = {
publicKey: queueConfig.push.vapidPublicKey,
privateKey: queueConfig.push.vapidPrivateKey
};
}
// Configure web-push with VAPID details
if (this.vapidKeys) {
webpush.setVapidDetails(
queueConfig.push.vapidEmail,
this.vapidKeys.publicKey,
this.vapidKeys.privateKey
);
}
}
/**
* Get the public VAPID key for client-side subscription
*/
getPublicVapidKey(): string | null {
return this.vapidKeys?.publicKey || null;
}
/**
* Load VAPID keys for push notifications
* In production, these should be stored securely and loaded from environment
*/
private loadVapidKeys() {
// Load from config module which uses SvelteKit's $env/dynamic/private
this.vapidKeys = {
publicKey: queueConfig.push.vapidPublicKey,
privateKey: queueConfig.push.vapidPrivateKey
};
}
/**
* Subscribe a client to push notifications
*/
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
console.log(`[PushService] Subscribing client ${clientId}`);
this.subscriptions.set(clientId, subscription);
// In production, store subscriptions in database
// For development, we'll keep them in memory
}
/**
* Get the public VAPID key for client-side subscription
*/
getPublicVapidKey(): string | null {
return this.vapidKeys?.publicKey || null;
}
/**
* Unsubscribe a client from push notifications
*/
async unsubscribe(clientId: string): Promise<void> {
console.log(`[PushService] Unsubscribing client ${clientId}`);
this.subscriptions.delete(clientId);
}
/**
* Subscribe a client to push notifications
*/
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
console.log(`[PushService] Subscribing client ${clientId}`);
this.subscriptions.set(clientId, subscription);
/**
* Send notification to all subscribed clients
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
if (this.subscriptions.size === 0) {
console.log('[PushService] No subscriptions, skipping notification');
return;
}
// In production, store subscriptions in database
// For development, we'll keep them in memory
}
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
console.log(`[PushService] Notification payload:`, payload);
/**
* Unsubscribe a client from push notifications
*/
async unsubscribe(clientId: string): Promise<void> {
console.log(`[PushService] Unsubscribing client ${clientId}`);
this.subscriptions.delete(clientId);
}
// In a real implementation, this would use web-push library
// For development/demo purposes, we'll simulate the notification
const notificationData = {
...payload,
timestamp: new Date().toISOString()
};
/**
* Send notification to all subscribed clients
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
if (this.subscriptions.size === 0) {
console.log('[PushService] No subscriptions, skipping notification');
return;
}
for (const [clientId, subscription] of this.subscriptions) {
try {
await this.sendToSubscription(subscription, notificationData);
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
} catch (error) {
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
// Remove invalid subscriptions
this.subscriptions.delete(clientId);
}
}
}
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
console.log(`[PushService] Notification payload:`, payload);
/**
* Send notification to specific subscription
*/
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
try {
const payload = JSON.stringify(data);
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload,
{
TTL: 60 * 60 * 24, // 24 hours
}
);
console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`);
} catch (error) {
// Check if subscription is expired/invalid
if ((error as any).statusCode === 410) {
console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`);
throw new Error('Subscription expired');
}
console.error('[PushService] Failed to send notification:', {
endpoint: subscription.endpoint.substring(0, 50) + '...',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
// In a real implementation, this would use web-push library
// For development/demo purposes, we'll simulate the notification
const notificationData = {
...payload,
timestamp: new Date().toISOString()
};
/**
* Send success notification when recipe extraction completes
*/
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName
? `Recipe "${recipeName}" has been extracted and saved successfully!`
: 'Your recipe extraction is complete and ready to view.',
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_complete',
itemId,
timestamp: Date.now()
}
};
for (const [clientId, subscription] of this.subscriptions) {
try {
await this.sendToSubscription(subscription, notificationData);
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
} catch (error) {
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
// Remove invalid subscriptions
this.subscriptions.delete(clientId);
}
}
}
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
}
/**
* Send notification to specific subscription
*/
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
try {
const payload = JSON.stringify(data);
await this.sendNotification(payload);
}
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload,
{
TTL: 60 * 60 * 24 // 24 hours
}
);
/**
* Send error notification when recipe extraction fails
*/
async notifyError(itemId: string, error: string): Promise<void> {
const payload: NotificationPayload = {
type: 'error',
itemId,
body: `Recipe extraction failed: ${error}. Tap to retry.`,
tag: `recipe-error-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_failed',
itemId,
error,
timestamp: Date.now()
}
};
console.log(
`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`
);
} catch (error) {
// Check if subscription is expired/invalid
if ((error as any).statusCode === 410) {
console.warn(
`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`
);
throw new Error('Subscription expired');
}
await this.sendNotification(payload);
}
console.error('[PushService] Failed to send notification:', {
endpoint: subscription.endpoint.substring(0, 50) + '...',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Send progress notification for long-running extractions
*/
async notifyProgress(itemId: string, phase: string): Promise<void> {
const payload: NotificationPayload = {
type: 'progress',
itemId,
body: `Recipe extraction in progress: ${phase}`,
tag: `recipe-progress-${itemId}`,
requireInteraction: false,
analytics: {
event: 'recipe_extraction_progress',
itemId,
phase,
timestamp: Date.now()
}
};
/**
* Send success notification when recipe extraction completes
*/
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName
? `Recipe "${recipeName}" has been extracted and saved successfully!`
: 'Your recipe extraction is complete and ready to view.',
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_complete',
itemId,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
}
/**
* Get subscription count for monitoring
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
await this.sendNotification(payload);
}
/**
* Clear all subscriptions (for testing/cleanup)
*/
clearAllSubscriptions(): void {
console.log('[PushService] Clearing all subscriptions');
this.subscriptions.clear();
}
/**
* Send error notification when recipe extraction fails
*/
async notifyError(itemId: string, error: string): Promise<void> {
const payload: NotificationPayload = {
type: 'error',
itemId,
body: `Recipe extraction failed: ${error}. Tap to retry.`,
tag: `recipe-error-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_failed',
itemId,
error,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
/**
* Send progress notification for long-running extractions
*/
async notifyProgress(itemId: string, phase: string): Promise<void> {
const payload: NotificationPayload = {
type: 'progress',
itemId,
body: `Recipe extraction in progress: ${phase}`,
tag: `recipe-progress-${itemId}`,
requireInteraction: false,
analytics: {
event: 'recipe_extraction_progress',
itemId,
phase,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
/**
* Get subscription count for monitoring
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
/**
* Clear all subscriptions (for testing/cleanup)
*/
clearAllSubscriptions(): void {
console.log('[PushService] Clearing all subscriptions');
this.subscriptions.clear();
}
}
// Singleton instance
export const pushNotificationService = new PushNotificationService();
export type { PushSubscription, NotificationPayload };
export type { PushSubscription, NotificationPayload };

View File

@@ -1,208 +1,212 @@
import { createLLM, checkModelAvailability } from './llm';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
import { logError } from './utils/logger';
const RecipeSchema = z.object({
name: z.string(),
servings: z.number().nullable(),
description: z.string().nullable(),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
})
).nullable(),
steps: z.array(z.string()).nullable(),
image: z.string().nullable().optional()
});
export type Recipe = z.infer<typeof RecipeSchema>;
/**
* Detect if the text contains a recipe using binary classification
* @param text - The text to analyze
* @returns True if a recipe is detected, false otherwise
*/
export async function detectRecipe(text: string): Promise<boolean> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe detection...');
console.log('[LLM] Model:', model);
console.log('[LLM] Text length:', text.length);
const detectionResponse = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: RECIPE_DETECTION_PROMPT
},
{
role: 'user',
content: `Does this text contain a recipe?\n\n${text}`
}
],
max_tokens: 10,
temperature: 0
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
console.log('[LLM] Detection response:', detectionResult);
return detectionResult.includes('yes');
} catch (e) {
logError('[LLM] Recipe detection error', e);
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError = errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') ||
errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
const modelCheck = await checkModelAvailability(model);
if (!modelCheck.available) {
throw new Error(modelCheck.message || `Model "${model}" is not available`);
}
}
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
}
}
/**
* Extract recipe data from text using LLM structured output
* @param text - The text containing the recipe
* @returns Parsed recipe object
*/
export async function parseRecipe(text: string): Promise<Recipe> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe parsing...');
console.log('[LLM] Model:', model);
const completion = await client.beta.chat.completions.parse({
model,
messages: [
{
role: 'system',
content: RECIPE_EXTRACTION_PROMPT
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
temperature: 0.3
});
const recipe = completion.choices[0].message.parsed;
console.log('[LLM] Parse response:', recipe?.name);
if (!recipe || !recipe.name) {
throw new Error('Failed to extract recipe - missing name');
}
return recipe;
} catch (e) {
logError('[LLM] Recipe parsing error', e);
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError = errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') ||
errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
const modelCheck = await checkModelAvailability(model);
if (!modelCheck.available) {
throw new Error(modelCheck.message || `Model "${model}" is not available`);
}
}
// If structured output fails, try standard completion
if ((e as any).message?.includes('response_format') ||
(e as any).message?.includes('structured output')) {
console.warn('[LLM] Falling back to standard completion');
return await parseRecipeWithStandardCompletion(text);
}
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
}
}
/**
* Complete workflow: detect recipe and parse if found
* @param text - The text to analyze
* @returns Parsed recipe object if detected, null otherwise
*/
export async function extractRecipe(text: string): Promise<Recipe | null> {
const isRecipe = await detectRecipe(text);
if (!isRecipe) {
return null;
}
return parseRecipe(text);
}
/**
* Fallback parser using standard completion (no structured output)
* Used when the model doesn't support beta.chat.completions.parse()
*/
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
const { client, model } = createLLM();
console.log('[LLM] Using standard completion fallback');
const completion = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["First step", "Second step", ...]
}
Convert all measurements to SI units (g, mL, °C).
Translate everything to Italian.
Extract ONLY what's in the text.`
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
max_tokens: 2000,
temperature: 0.3
});
const jsonResponse = completion.choices[0].message.content;
if (!jsonResponse) {
throw new Error('Empty response from LLM');
}
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
// Parse and validate JSON (remove code fences if present)
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
const parsedData = JSON.parse(cleanedJson);
const recipe = RecipeSchema.parse(parsedData);
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
return recipe;
}
import { createLLM, checkModelAvailability } from './llm';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
import { logError } from './utils/logger';
const RecipeSchema = z.object({
name: z.string(),
servings: z.number().nullable(),
description: z.string().nullable(),
ingredients: z
.array(
z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
})
)
.nullable(),
steps: z.array(z.string()).nullable(),
image: z.string().nullable().optional()
});
export type Recipe = z.infer<typeof RecipeSchema>;
/**
* Detect if the text contains a recipe using binary classification
* @param text - The text to analyze
* @returns True if a recipe is detected, false otherwise
*/
export async function detectRecipe(text: string): Promise<boolean> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe detection...');
console.log('[LLM] Model:', model);
console.log('[LLM] Text length:', text.length);
const detectionResponse = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: RECIPE_DETECTION_PROMPT
},
{
role: 'user',
content: `Does this text contain a recipe?\n\n${text}`
}
],
max_tokens: 10,
temperature: 0
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
console.log('[LLM] Detection response:', detectionResult);
return detectionResult.includes('yes');
} catch (e) {
logError('[LLM] Recipe detection error', e);
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError =
errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
const modelCheck = await checkModelAvailability(model);
if (!modelCheck.available) {
throw new Error(modelCheck.message || `Model "${model}" is not available`);
}
}
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
}
}
/**
* Extract recipe data from text using LLM structured output
* @param text - The text containing the recipe
* @returns Parsed recipe object
*/
export async function parseRecipe(text: string): Promise<Recipe> {
try {
const { client, model } = createLLM();
console.log('[LLM] Starting recipe parsing...');
console.log('[LLM] Model:', model);
const completion = await client.beta.chat.completions.parse({
model,
messages: [
{
role: 'system',
content: RECIPE_EXTRACTION_PROMPT
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
temperature: 0.3
});
const recipe = completion.choices[0].message.parsed;
console.log('[LLM] Parse response:', recipe?.name);
if (!recipe || !recipe.name) {
throw new Error('Failed to extract recipe - missing name');
}
return recipe;
} catch (e) {
logError('[LLM] Recipe parsing error', e);
// Check if this is a model-related error
const errorMessage = (e as Error).message || '';
const isModelError =
errorMessage.includes('400') &&
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
if (isModelError) {
const { model } = createLLM();
const modelCheck = await checkModelAvailability(model);
if (!modelCheck.available) {
throw new Error(modelCheck.message || `Model "${model}" is not available`);
}
}
// If structured output fails, try standard completion
if (
(e as any).message?.includes('response_format') ||
(e as any).message?.includes('structured output')
) {
console.warn('[LLM] Falling back to standard completion');
return await parseRecipeWithStandardCompletion(text);
}
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
}
}
/**
* Complete workflow: detect recipe and parse if found
* @param text - The text to analyze
* @returns Parsed recipe object if detected, null otherwise
*/
export async function extractRecipe(text: string): Promise<Recipe | null> {
const isRecipe = await detectRecipe(text);
if (!isRecipe) {
return null;
}
return parseRecipe(text);
}
/**
* Fallback parser using standard completion (no structured output)
* Used when the model doesn't support beta.chat.completions.parse()
*/
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
const { client, model } = createLLM();
console.log('[LLM] Using standard completion fallback');
const completion = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["First step", "Second step", ...]
}
Convert all measurements to SI units (g, mL, °C).
Translate everything to Italian.
Extract ONLY what's in the text.`
},
{
role: 'user',
content: `Extract the recipe from this text:\n\n${text}`
}
],
max_tokens: 2000,
temperature: 0.3
});
const jsonResponse = completion.choices[0].message.content;
if (!jsonResponse) {
throw new Error('Empty response from LLM');
}
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
// Parse and validate JSON (remove code fences if present)
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
const parsedData = JSON.parse(cleanedJson);
const recipe = RecipeSchema.parse(parsedData);
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
return recipe;
}

View File

@@ -1,9 +1,9 @@
/**
* Queue Manager - Core queue operations and event management
*
*
* Manages an in-memory queue of Instagram URL processing jobs.
* Provides CRUD operations and pub/sub mechanism for queue updates.
*
*
* Architecture: Domain Layer (Hexagonal Architecture)
* - Port: Defines queue operations interface
* - Implementation: In-memory Map-based storage
@@ -16,427 +16,428 @@ import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback
/**
* Singleton queue manager for processing Instagram URLs
*
*
* Features:
* - FIFO queue with unique IDs
* - Status tracking and updates
* - Progress event accumulation
* - Retry support for failed items
* - Pub/sub for real-time updates
*
*
* @example
* ```typescript
* import { queueManager } from './QueueManager';
*
*
* // Add item to queue
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
*
*
* // Subscribe to updates
* const unsubscribe = queueManager.subscribe((update) => {
* console.log('Item updated:', update);
* });
*
*
* // Get all items
* const items = queueManager.getAll();
* ```
*/
export class QueueManager {
/** Map of queue items by ID */
private items: Map<string, QueueItem> = new Map();
/** Set of subscriber callbacks */
private subscribers: Set<QueueUpdateCallback> = new Set();
/**
* Add URL to processing queue
*
* @param url - Instagram URL to process
* @returns Newly created queue item
*
* @example
* ```typescript
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
* console.log('Queued with ID:', item.id);
* ```
*/
enqueue(url: string): QueueItem {
const now = new Date().toISOString();
const item: QueueItem = {
id: uuidv4(),
url,
status: 'pending',
enqueuedAt: now,
createdAt: now,
updatedAt: now,
phases: [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
],
logs: [],
progressEvents: [],
retryCount: 0,
maxRetries: 3
};
this.items.set(item.id, item);
this.notifySubscribers({
type: 'status_change',
itemId: item.id,
status: 'pending',
url: item.url,
timestamp: now,
progress: item.phases
});
return item;
}
/**
* Get next pending item for processing (FIFO)
*
* Automatically marks the item as in_progress when dequeued.
*
* @returns Next pending item, or null if queue is empty
*
* @example
* ```typescript
* const item = queueManager.dequeue();
* if (item) {
* // Process item
* console.log('Processing:', item.url);
* }
* ```
*/
dequeue(): QueueItem | null {
for (const item of this.items.values()) {
if (item.status === 'pending') {
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
return item;
}
}
return null;
}
/**
* Update item status and optional data
*
* Handles status-specific logic:
* - Sets startedAt when transitioning to in_progress
* - Sets completedAt when transitioning to success/error
* - Updates currentPhase for in_progress status
*
* @param itemId - ID of item to update
* @param status - New status
* @param data - Optional additional data to merge into item
*
* @example
* ```typescript
* queueManager.updateStatus(itemId, 'in_progress', {
* phase: 'parsing'
* });
*
* queueManager.updateStatus(itemId, 'success', {
* recipe: parsedRecipe,
* tandoorRecipeId: 123
* });
* ```
*/
updateStatus(
itemId: string,
status: QueueItemStatus,
data?: any
): void {
const item = this.items.get(itemId);
if (!item) return;
const now = new Date().toISOString();
item.status = status;
item.updatedAt = now;
// Update phase progress
if (status === 'in_progress' && data?.phase) {
item.currentPhase = data.phase;
if (!item.startedAt) {
item.startedAt = now;
}
// Update phases array
const phaseIndex = item.phases.findIndex(p => p.name === data.phase);
if (phaseIndex >= 0) {
// Mark previous phases as completed
for (let i = 0; i < phaseIndex; i++) {
if (item.phases[i].status === 'in_progress') {
item.phases[i].status = 'completed';
item.phases[i].completedAt = now;
}
}
// Mark current phase as in progress
item.phases[phaseIndex].status = 'in_progress';
item.phases[phaseIndex].startedAt = now;
}
}
if (status === 'success') {
item.completedAt = now;
// Mark all phases as completed
item.phases.forEach(phase => {
if (phase.status !== 'completed') {
phase.status = 'completed';
phase.completedAt = now;
}
});
}
if (status === 'error' || status === 'unhealthy') {
item.completedAt = now;
// Mark current phase as error
if (item.currentPhase) {
const phaseIndex = item.phases.findIndex(p => p.name === item.currentPhase);
if (phaseIndex >= 0) {
item.phases[phaseIndex].status = 'error';
item.phases[phaseIndex].error = data?.error?.message;
}
}
}
// Wrap results in results object
if (data?.extractedText || data?.thumbnail !== undefined || data?.recipe || data?.tandoorRecipeId) {
if (!item.results) {
item.results = {};
}
if (data.extractedText) {
item.results.extractedText = data.extractedText;
item.extractedText = data.extractedText; // Keep legacy
}
if (data.thumbnail !== undefined) {
item.results.thumbnail = data.thumbnail;
item.thumbnail = data.thumbnail; // Keep legacy
}
if (data.recipe) {
item.results.recipe = data.recipe;
item.recipe = data.recipe; // Keep legacy
}
if (data.tandoorRecipeId) {
item.results.tandoorRecipeId = data.tandoorRecipeId;
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
// Construct Tandoor URL
if (tandoorConfig.serverUrl) {
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
}
}
}
if (data?.error) {
item.error = data.error;
}
// Notify subscribers with enhanced update
this.notifySubscribers({
type: 'status_change',
itemId,
status,
timestamp: now,
url: item.url,
phase: item.currentPhase,
progress: item.phases,
results: item.results,
error: item.error,
...data
});
}
/**
* Add progress event to item's history
*
* Also extracts message into logs array for easy display.
*
* @param itemId - ID of item
* @param event - Progress event to add
*
* @example
* ```typescript
* queueManager.addProgressEvent(itemId, {
* type: 'status',
* message: 'Extracting from Instagram...',
* timestamp: new Date().toISOString()
* });
* ```
*/
addProgressEvent(itemId: string, event: any): void {
const item = this.items.get(itemId);
if (!item) return;
item.progressEvents.push(event);
item.logs.push(event.message);
this.notifySubscribers({
type: 'progress',
itemId,
status: item.status,
timestamp: new Date().toISOString(),
data: { event }
});
}
/**
* Remove item from queue
*
* @param itemId - ID of item to remove
* @returns true if item was removed, false if not found
*
* @example
* ```typescript
* const removed = queueManager.remove(itemId);
* if (removed) {
* console.log('Item removed successfully');
* }
* ```
*/
remove(itemId: string): boolean {
const deleted = this.items.delete(itemId);
if (deleted) {
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'error', // Use error to signal removal
timestamp: new Date().toISOString(),
data: { removed: true }
});
}
return deleted;
}
/**
* Retry a failed or unhealthy item
*
* Resets item to pending status and clears error state.
* Cannot retry items currently in progress.
*
* @param itemId - ID of item to retry
* @returns true if retry was initiated, false otherwise
*
* @example
* ```typescript
* const retried = queueManager.retry(itemId);
* if (retried) {
* console.log('Item queued for retry');
* } else {
* console.log('Cannot retry (item in progress or not found)');
* }
* ```
*/
retry(itemId: string): boolean {
const item = this.items.get(itemId);
if (!item || item.status === 'in_progress') return false;
item.retryCount++;
item.status = 'pending';
item.currentPhase = undefined;
item.error = undefined;
item.startedAt = undefined;
item.completedAt = undefined;
// Reset phases to pending
item.phases = [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
];
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'pending',
timestamp: new Date().toISOString(),
progress: item.phases,
data: { retryCount: item.retryCount }
});
return true;
}
/**
* Get all queue items
*
* @returns Array of all queue items
*
* @example
* ```typescript
* const items = queueManager.getAll();
* console.log(`Queue has ${items.length} items`);
* ```
*/
getAll(): QueueItem[] {
return Array.from(this.items.values());
}
/**
* Get single item by ID
*
* @param itemId - ID of item to retrieve
* @returns Queue item or undefined if not found
*
* @example
* ```typescript
* const item = queueManager.get(itemId);
* if (item) {
* console.log('Status:', item.status);
* }
* ```
*/
get(itemId: string): QueueItem | undefined {
return this.items.get(itemId);
}
/**
* Subscribe to queue updates
*
* Callback will be called whenever any item is updated.
*
* @param callback - Function to call on each update
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = queueManager.subscribe((update) => {
* console.log('Update:', update.itemId, update.status);
* });
*
* // Later...
* unsubscribe();
* ```
*/
subscribe(callback: QueueUpdateCallback): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
/**
* Notify all subscribers of an update
*
* Handles errors in individual subscribers to prevent one
* bad subscriber from affecting others.
*
* @param update - Update to broadcast
*/
private notifySubscribers(update: QueueStatusUpdate): void {
for (const callback of this.subscribers) {
try {
callback(update);
} catch (err) {
logError('[QueueManager] Subscriber error', err);
}
}
}
/** Map of queue items by ID */
private items: Map<string, QueueItem> = new Map();
/** Set of subscriber callbacks */
private subscribers: Set<QueueUpdateCallback> = new Set();
/**
* Add URL to processing queue
*
* @param url - Instagram URL to process
* @returns Newly created queue item
*
* @example
* ```typescript
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
* console.log('Queued with ID:', item.id);
* ```
*/
enqueue(url: string): QueueItem {
const now = new Date().toISOString();
const item: QueueItem = {
id: uuidv4(),
url,
status: 'pending',
enqueuedAt: now,
createdAt: now,
updatedAt: now,
phases: [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
],
logs: [],
progressEvents: [],
retryCount: 0,
maxRetries: 3
};
this.items.set(item.id, item);
this.notifySubscribers({
type: 'status_change',
itemId: item.id,
status: 'pending',
url: item.url,
timestamp: now,
progress: item.phases
});
return item;
}
/**
* Get next pending item for processing (FIFO)
*
* Automatically marks the item as in_progress when dequeued.
*
* @returns Next pending item, or null if queue is empty
*
* @example
* ```typescript
* const item = queueManager.dequeue();
* if (item) {
* // Process item
* console.log('Processing:', item.url);
* }
* ```
*/
dequeue(): QueueItem | null {
for (const item of this.items.values()) {
if (item.status === 'pending') {
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
return item;
}
}
return null;
}
/**
* Update item status and optional data
*
* Handles status-specific logic:
* - Sets startedAt when transitioning to in_progress
* - Sets completedAt when transitioning to success/error
* - Updates currentPhase for in_progress status
*
* @param itemId - ID of item to update
* @param status - New status
* @param data - Optional additional data to merge into item
*
* @example
* ```typescript
* queueManager.updateStatus(itemId, 'in_progress', {
* phase: 'parsing'
* });
*
* queueManager.updateStatus(itemId, 'success', {
* recipe: parsedRecipe,
* tandoorRecipeId: 123
* });
* ```
*/
updateStatus(itemId: string, status: QueueItemStatus, data?: any): void {
const item = this.items.get(itemId);
if (!item) return;
const now = new Date().toISOString();
item.status = status;
item.updatedAt = now;
// Update phase progress
if (status === 'in_progress' && data?.phase) {
item.currentPhase = data.phase;
if (!item.startedAt) {
item.startedAt = now;
}
// Update phases array
const phaseIndex = item.phases.findIndex((p) => p.name === data.phase);
if (phaseIndex >= 0) {
// Mark previous phases as completed
for (let i = 0; i < phaseIndex; i++) {
if (item.phases[i].status === 'in_progress') {
item.phases[i].status = 'completed';
item.phases[i].completedAt = now;
}
}
// Mark current phase as in progress
item.phases[phaseIndex].status = 'in_progress';
item.phases[phaseIndex].startedAt = now;
}
}
if (status === 'success') {
item.completedAt = now;
// Mark all phases as completed
item.phases.forEach((phase) => {
if (phase.status !== 'completed') {
phase.status = 'completed';
phase.completedAt = now;
}
});
}
if (status === 'error' || status === 'unhealthy') {
item.completedAt = now;
// Mark current phase as error
if (item.currentPhase) {
const phaseIndex = item.phases.findIndex((p) => p.name === item.currentPhase);
if (phaseIndex >= 0) {
item.phases[phaseIndex].status = 'error';
item.phases[phaseIndex].error = data?.error?.message;
}
}
}
// Wrap results in results object
if (
data?.extractedText ||
data?.thumbnail !== undefined ||
data?.recipe ||
data?.tandoorRecipeId
) {
if (!item.results) {
item.results = {};
}
if (data.extractedText) {
item.results.extractedText = data.extractedText;
item.extractedText = data.extractedText; // Keep legacy
}
if (data.thumbnail !== undefined) {
item.results.thumbnail = data.thumbnail;
item.thumbnail = data.thumbnail; // Keep legacy
}
if (data.recipe) {
item.results.recipe = data.recipe;
item.recipe = data.recipe; // Keep legacy
}
if (data.tandoorRecipeId) {
item.results.tandoorRecipeId = data.tandoorRecipeId;
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
// Construct Tandoor URL
if (tandoorConfig.serverUrl) {
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
}
}
}
if (data?.error) {
item.error = data.error;
}
// Notify subscribers with enhanced update
this.notifySubscribers({
type: 'status_change',
itemId,
status,
timestamp: now,
url: item.url,
phase: item.currentPhase,
progress: item.phases,
results: item.results,
error: item.error,
...data
});
}
/**
* Add progress event to item's history
*
* Also extracts message into logs array for easy display.
*
* @param itemId - ID of item
* @param event - Progress event to add
*
* @example
* ```typescript
* queueManager.addProgressEvent(itemId, {
* type: 'status',
* message: 'Extracting from Instagram...',
* timestamp: new Date().toISOString()
* });
* ```
*/
addProgressEvent(itemId: string, event: any): void {
const item = this.items.get(itemId);
if (!item) return;
item.progressEvents.push(event);
item.logs.push(event.message);
this.notifySubscribers({
type: 'progress',
itemId,
status: item.status,
timestamp: new Date().toISOString(),
data: { event }
});
}
/**
* Remove item from queue
*
* @param itemId - ID of item to remove
* @returns true if item was removed, false if not found
*
* @example
* ```typescript
* const removed = queueManager.remove(itemId);
* if (removed) {
* console.log('Item removed successfully');
* }
* ```
*/
remove(itemId: string): boolean {
const deleted = this.items.delete(itemId);
if (deleted) {
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'error', // Use error to signal removal
timestamp: new Date().toISOString(),
data: { removed: true }
});
}
return deleted;
}
/**
* Retry a failed or unhealthy item
*
* Resets item to pending status and clears error state.
* Cannot retry items currently in progress.
*
* @param itemId - ID of item to retry
* @returns true if retry was initiated, false otherwise
*
* @example
* ```typescript
* const retried = queueManager.retry(itemId);
* if (retried) {
* console.log('Item queued for retry');
* } else {
* console.log('Cannot retry (item in progress or not found)');
* }
* ```
*/
retry(itemId: string): boolean {
const item = this.items.get(itemId);
if (!item || item.status === 'in_progress') return false;
item.retryCount++;
item.status = 'pending';
item.currentPhase = undefined;
item.error = undefined;
item.startedAt = undefined;
item.completedAt = undefined;
// Reset phases to pending
item.phases = [
{ name: 'extraction', status: 'pending' },
{ name: 'parsing', status: 'pending' },
{ name: 'uploading', status: 'pending' }
];
this.notifySubscribers({
type: 'status_change',
itemId,
status: 'pending',
timestamp: new Date().toISOString(),
progress: item.phases,
data: { retryCount: item.retryCount }
});
return true;
}
/**
* Get all queue items
*
* @returns Array of all queue items
*
* @example
* ```typescript
* const items = queueManager.getAll();
* console.log(`Queue has ${items.length} items`);
* ```
*/
getAll(): QueueItem[] {
return Array.from(this.items.values());
}
/**
* Get single item by ID
*
* @param itemId - ID of item to retrieve
* @returns Queue item or undefined if not found
*
* @example
* ```typescript
* const item = queueManager.get(itemId);
* if (item) {
* console.log('Status:', item.status);
* }
* ```
*/
get(itemId: string): QueueItem | undefined {
return this.items.get(itemId);
}
/**
* Subscribe to queue updates
*
* Callback will be called whenever any item is updated.
*
* @param callback - Function to call on each update
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = queueManager.subscribe((update) => {
* console.log('Update:', update.itemId, update.status);
* });
*
* // Later...
* unsubscribe();
* ```
*/
subscribe(callback: QueueUpdateCallback): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
/**
* Notify all subscribers of an update
*
* Handles errors in individual subscribers to prevent one
* bad subscriber from affecting others.
*
* @param update - Update to broadcast
*/
private notifySubscribers(update: QueueStatusUpdate): void {
for (const callback of this.subscribers) {
try {
callback(update);
} catch (err) {
logError('[QueueManager] Subscriber error', err);
}
}
}
}
/**
* Singleton instance of QueueManager
*
*
* Use this instance throughout the application to ensure
* all components interact with the same queue.
*/

View File

@@ -1,11 +1,11 @@
/**
* Queue Processor - Orchestrates async processing of queue items
*
*
* Manages concurrent processing of Instagram URLs through three phases:
* 1. Extraction - Browser automation to extract text and thumbnail
* 2. Parsing - LLM-based recipe extraction
* 3. Uploading - Automatic upload to Tandoor (if configured)
*
*
* Architecture: Domain Layer (Hexagonal Architecture)
* - Domain Logic: Orchestrates processing workflow
* - Uses Ports: extraction.ts, parser.ts, tandoor.ts (secondary adapters)
@@ -23,422 +23,424 @@ import type { QueueItem } from './types';
/**
* Queue processor with configurable concurrency
*
*
* Features:
* - Concurrent processing (default: 2 simultaneous items)
* - Three-phase pipeline: extraction → parsing → uploading
* - Error classification (recoverable vs non-recoverable)
* - Progress tracking via QueueManager
* - Automatic start on instantiation
*
*
* @example
* ```typescript
* import { queueProcessor } from './QueueProcessor';
*
*
* // Processor auto-starts on import
* // Add items to queue and they'll be processed automatically
*
*
* // Stop processing (e.g., for maintenance)
* queueProcessor.stop();
*
*
* // Resume processing
* queueProcessor.start();
* ```
*/
export class QueueProcessor {
/** Whether processor is actively running */
private processing = false;
/** Maximum number of items to process simultaneously */
private concurrency = queueConfig.concurrency;
/** Number of workers currently processing items */
private activeWorkers = 0;
/** Unsubscribe function for queue manager subscription */
private unsubscribeFromQueue?: () => void;
constructor() {
// Subscribe to queue updates to process new items immediately
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
// Trigger processing when new items are enqueued (status_change to 'pending')
if (update.type === 'status_change' && update.status === 'pending') {
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
// Use immediate processing (no timeout) for newly enqueued items
setTimeout(() => this.processNextBatch(), 0);
}
});
}
/**
* Start processing queue
*
* Begins dequeuing and processing items up to concurrency limit.
* Safe to call multiple times - will not start duplicates.
*/
start(): void {
if (this.processing) return;
this.processing = true;
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
this.processNextBatch();
}
/**
* Stop processing queue
*
* Prevents new items from being dequeued.
* Items currently in progress will complete.
*/
stop(): void {
this.processing = false;
console.log('[QueueProcessor] Stopped');
// Cleanup subscription when stopping
if (this.unsubscribeFromQueue) {
this.unsubscribeFromQueue();
this.unsubscribeFromQueue = undefined;
}
}
/**
* Process items up to concurrency limit
*
* Dequeues pending items and starts processing them.
* Automatically called recursively to maintain worker pool.
*/
private async processNextBatch(): Promise<void> {
if (!this.processing) return;
// Start new workers up to concurrency limit
while (this.activeWorkers < this.concurrency) {
const item = queueManager.dequeue();
if (!item) break;
this.activeWorkers++;
console.log(`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
this.processItem(item)
.finally(() => {
this.activeWorkers--;
console.log(`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
// Try to process next item immediately
setTimeout(() => this.processNextBatch(), 0);
});
}
// Check again after shorter delay if still processing and no active workers
if (this.processing && this.activeWorkers === 0) {
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
}
}
/**
* Process a single queue item through all phases
*
* Executes three phases sequentially:
* 1. Extraction - Extract content from Instagram
* 2. Parsing - Parse recipe from extracted text
* 3. Uploading - Upload to Tandoor (if configured)
*
* On success: marks item as 'success'
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
*
* @param item - Queue item to process
*/
private async processItem(item: QueueItem): Promise<void> {
try {
console.log(`[QueueProcessor] Processing ${item.url}`);
// Phase 1: Extraction
await this.extractionPhase(item);
// Phase 2: Parsing
await this.parsingPhase(item);
// Phase 3: Tandoor Upload (if enabled)
await this.uploadPhase(item);
// Success
queueManager.updateStatus(item.id, 'success');
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
// Send push notification
await this.sendPushNotification(item, 'success');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const recoverable = this.isRecoverableError(error);
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
error: {
phase: item.currentPhase || 'extraction',
message: errorMsg,
recoverable,
timestamp: new Date().toISOString()
}
});
// Send push notification
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
}
}
/**
* Phase 1: Extract text and thumbnail from Instagram
*
* Uses browser automation to load Instagram post and extract:
* - Recipe text (from caption, comments, etc.)
* - Thumbnail image (from meta tags or screenshot)
*
* Progress events are captured and added to queue item.
*
* @param item - Queue item being processed
* @throws Error if extraction fails
*/
private async extractionPhase(item: QueueItem): Promise<void> {
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction'
});
const progressCallback = (event: ProgressEvent) => {
queueManager.addProgressEvent(item.id, event);
};
console.log(`[QueueProcessor] Extracting: ${item.url}`);
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction',
extractedText: extracted.bodyText,
thumbnail: extracted.thumbnail
});
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
}
/**
* Phase 2: Parse recipe from extracted text
*
* Uses LLM to extract structured recipe data:
* - Recipe name
* - Ingredients with amounts and units
* - Instructions/steps
* - Servings, times, etc.
*
* Enriches recipe with metadata (URL, thumbnail).
*
* @param item - Queue item being processed
* @throws Error if parsing fails or no recipe found
*/
private async parsingPhase(item: QueueItem): Promise<void> {
if (!item.extractedText) {
throw new Error('No extracted text available for parsing');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Parsing recipe with LLM...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
const recipe = await extractRecipe(item.extractedText);
if (!recipe) {
throw new Error('Failed to parse recipe from extracted text');
}
// Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${item.url}`;
} else {
recipe.description = `Link: ${item.url}`;
}
if (item.thumbnail) {
recipe.image = item.thumbnail;
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing',
recipe
});
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
}
/**
* Phase 3: Upload to Tandoor (automatic)
*
* If Tandoor is configured (TANDOOR_TOKEN env var set):
* - Uploads recipe with ingredients and steps
* - Attempts to upload thumbnail/image
* - Image upload failure is non-fatal (logged but doesn't fail item)
*
* If Tandoor not configured: skips silently
*
* @param item - Queue item being processed
* @throws Error if Tandoor upload fails
*/
private async uploadPhase(item: QueueItem): Promise<void> {
// Check if Tandoor is enabled
if (!queueConfig.tandoor.enabled) {
// Skip if Tandoor not configured
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor not configured, skipping upload',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
return;
}
if (!item.recipe) {
throw new Error('No recipe available for upload');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe to Tandoor...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
// Upload recipe
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
// Upload image if available
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
});
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor upload completed',
timestamp: new Date().toISOString()
});
}
/**
* Determine if error is recoverable
*
* Recoverable errors (unhealthy):
* - Network timeouts
* - Connection failures
* - Image upload failures
* - Thumbnail extraction failures
*
* Non-recoverable errors (error):
* - Invalid URL format
* - Authentication failures
* - Parsing failures (no recipe found)
*
* @param error - Error to classify
* @returns true if error is recoverable, false otherwise
*/
private isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
// Recoverable errors
const recoverablePatterns = [
'timeout',
'network',
'econnrefused',
'enotfound',
'image upload failed',
'thumbnail',
'etimeout',
'fetch failed'
];
return recoverablePatterns.some(pattern => message.includes(pattern));
}
/**
* Send Web Push notification for queue item completion
*
* Sends appropriate notification based on processing status:
* - success: Recipe extraction complete with details
* - error/unhealthy: Extraction failed with retry option
*
* @param item - Queue item that completed
* @param status - Completion status (success, unhealthy, error)
*/
private async sendPushNotification(
item: QueueItem,
status: 'success' | 'unhealthy' | 'error'
): Promise<void> {
try {
switch (status) {
case 'success':
await pushNotificationService.notifySuccess(
item.id,
item.results?.recipe?.name,
item.results?.tandoorUrl
);
break;
case 'error':
case 'unhealthy':
const errorMessage = item.error?.message || 'Processing failed';
await pushNotificationService.notifyError(item.id, errorMessage);
break;
default:
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
}
} catch (error) {
logError('[QueueProcessor] Failed to send push notification', error);
// Don't let notification failures break processing
}
}
/** Whether processor is actively running */
private processing = false;
/** Maximum number of items to process simultaneously */
private concurrency = queueConfig.concurrency;
/** Number of workers currently processing items */
private activeWorkers = 0;
/** Unsubscribe function for queue manager subscription */
private unsubscribeFromQueue?: () => void;
constructor() {
// Subscribe to queue updates to process new items immediately
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
// Trigger processing when new items are enqueued (status_change to 'pending')
if (update.type === 'status_change' && update.status === 'pending') {
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
// Use immediate processing (no timeout) for newly enqueued items
setTimeout(() => this.processNextBatch(), 0);
}
});
}
/**
* Start processing queue
*
* Begins dequeuing and processing items up to concurrency limit.
* Safe to call multiple times - will not start duplicates.
*/
start(): void {
if (this.processing) return;
this.processing = true;
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
this.processNextBatch();
}
/**
* Stop processing queue
*
* Prevents new items from being dequeued.
* Items currently in progress will complete.
*/
stop(): void {
this.processing = false;
console.log('[QueueProcessor] Stopped');
// Cleanup subscription when stopping
if (this.unsubscribeFromQueue) {
this.unsubscribeFromQueue();
this.unsubscribeFromQueue = undefined;
}
}
/**
* Process items up to concurrency limit
*
* Dequeues pending items and starts processing them.
* Automatically called recursively to maintain worker pool.
*/
private async processNextBatch(): Promise<void> {
if (!this.processing) return;
// Start new workers up to concurrency limit
while (this.activeWorkers < this.concurrency) {
const item = queueManager.dequeue();
if (!item) break;
this.activeWorkers++;
console.log(
`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
);
this.processItem(item).finally(() => {
this.activeWorkers--;
console.log(
`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
);
// Try to process next item immediately
setTimeout(() => this.processNextBatch(), 0);
});
}
// Check again after shorter delay if still processing and no active workers
if (this.processing && this.activeWorkers === 0) {
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
}
}
/**
* Process a single queue item through all phases
*
* Executes three phases sequentially:
* 1. Extraction - Extract content from Instagram
* 2. Parsing - Parse recipe from extracted text
* 3. Uploading - Upload to Tandoor (if configured)
*
* On success: marks item as 'success'
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
*
* @param item - Queue item to process
*/
private async processItem(item: QueueItem): Promise<void> {
try {
console.log(`[QueueProcessor] Processing ${item.url}`);
// Phase 1: Extraction
await this.extractionPhase(item);
// Phase 2: Parsing
await this.parsingPhase(item);
// Phase 3: Tandoor Upload (if enabled)
await this.uploadPhase(item);
// Success
queueManager.updateStatus(item.id, 'success');
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
// Send push notification
await this.sendPushNotification(item, 'success');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const recoverable = this.isRecoverableError(error);
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
error: {
phase: item.currentPhase || 'extraction',
message: errorMsg,
recoverable,
timestamp: new Date().toISOString()
}
});
// Send push notification
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
}
}
/**
* Phase 1: Extract text and thumbnail from Instagram
*
* Uses browser automation to load Instagram post and extract:
* - Recipe text (from caption, comments, etc.)
* - Thumbnail image (from meta tags or screenshot)
*
* Progress events are captured and added to queue item.
*
* @param item - Queue item being processed
* @throws Error if extraction fails
*/
private async extractionPhase(item: QueueItem): Promise<void> {
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction'
});
const progressCallback = (event: ProgressEvent) => {
queueManager.addProgressEvent(item.id, event);
};
console.log(`[QueueProcessor] Extracting: ${item.url}`);
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'extraction',
extractedText: extracted.bodyText,
thumbnail: extracted.thumbnail
});
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
}
/**
* Phase 2: Parse recipe from extracted text
*
* Uses LLM to extract structured recipe data:
* - Recipe name
* - Ingredients with amounts and units
* - Instructions/steps
* - Servings, times, etc.
*
* Enriches recipe with metadata (URL, thumbnail).
*
* @param item - Queue item being processed
* @throws Error if parsing fails or no recipe found
*/
private async parsingPhase(item: QueueItem): Promise<void> {
if (!item.extractedText) {
throw new Error('No extracted text available for parsing');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Parsing recipe with LLM...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
const recipe = await extractRecipe(item.extractedText);
if (!recipe) {
throw new Error('Failed to parse recipe from extracted text');
}
// Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${item.url}`;
} else {
recipe.description = `Link: ${item.url}`;
}
if (item.thumbnail) {
recipe.image = item.thumbnail;
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'parsing',
recipe
});
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
}
/**
* Phase 3: Upload to Tandoor (automatic)
*
* If Tandoor is configured (TANDOOR_TOKEN env var set):
* - Uploads recipe with ingredients and steps
* - Attempts to upload thumbnail/image
* - Image upload failure is non-fatal (logged but doesn't fail item)
*
* If Tandoor not configured: skips silently
*
* @param item - Queue item being processed
* @throws Error if Tandoor upload fails
*/
private async uploadPhase(item: QueueItem): Promise<void> {
// Check if Tandoor is enabled
if (!queueConfig.tandoor.enabled) {
// Skip if Tandoor not configured
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor not configured, skipping upload',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
return;
}
if (!item.recipe) {
throw new Error('No recipe available for upload');
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading'
});
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe to Tandoor...',
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
// Upload recipe
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
// Upload image if available
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
});
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor upload completed',
timestamp: new Date().toISOString()
});
}
/**
* Determine if error is recoverable
*
* Recoverable errors (unhealthy):
* - Network timeouts
* - Connection failures
* - Image upload failures
* - Thumbnail extraction failures
*
* Non-recoverable errors (error):
* - Invalid URL format
* - Authentication failures
* - Parsing failures (no recipe found)
*
* @param error - Error to classify
* @returns true if error is recoverable, false otherwise
*/
private isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
// Recoverable errors
const recoverablePatterns = [
'timeout',
'network',
'econnrefused',
'enotfound',
'image upload failed',
'thumbnail',
'etimeout',
'fetch failed'
];
return recoverablePatterns.some((pattern) => message.includes(pattern));
}
/**
* Send Web Push notification for queue item completion
*
* Sends appropriate notification based on processing status:
* - success: Recipe extraction complete with details
* - error/unhealthy: Extraction failed with retry option
*
* @param item - Queue item that completed
* @param status - Completion status (success, unhealthy, error)
*/
private async sendPushNotification(
item: QueueItem,
status: 'success' | 'unhealthy' | 'error'
): Promise<void> {
try {
switch (status) {
case 'success':
await pushNotificationService.notifySuccess(
item.id,
item.results?.recipe?.name,
item.results?.tandoorUrl
);
break;
case 'error':
case 'unhealthy':
const errorMessage = item.error?.message || 'Processing failed';
await pushNotificationService.notifyError(item.id, errorMessage);
break;
default:
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
}
} catch (error) {
logError('[QueueProcessor] Failed to send push notification', error);
// Don't let notification failures break processing
}
}
}
/**
* Singleton instance of QueueProcessor
*
*
* Auto-starts on module import to begin processing queue.
*/
export const queueProcessor = new QueueProcessor();

View File

@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
/**
* Server-side configuration for the async queue system
* Uses SvelteKit's $env/dynamic/private for runtime environment access
*
*
* Environment Variables:
* - QUEUE_CONCURRENCY: Number of items to process concurrently (default: 2)
* - QUEUE_MAX_RETRIES: Maximum retry attempts for failed items (default: 3)
@@ -29,7 +29,9 @@ export const queueConfig = {
/** Web Push notification settings */
push: {
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPublicKey:
env.VAPID_PUBLIC_KEY ||
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: env.VAPID_EMAIL || 'mailto:admin@example.com'
}

View File

@@ -1,6 +1,6 @@
/**
* Type definitions for the async in-memory processing queue
*
*
* This module defines the core data structures for queue items,
* status updates, and callbacks used throughout the queue system.
*/
@@ -15,12 +15,7 @@ import type { ProgressEvent } from '$lib/server/extraction';
* - unhealthy: Recoverable error occurred, can be retried
* - error: Non-recoverable error occurred
*/
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'success'
| 'unhealthy'
| 'error';
export type QueueItemStatus = 'pending' | 'in_progress' | 'success' | 'unhealthy' | 'error';
/**
* Processing phases for queue items
@@ -28,26 +23,23 @@ export type QueueItemStatus =
* - parsing: Parsing recipe from extracted text
* - uploading: Uploading recipe to Tandoor
*/
export type ProcessingPhase =
| 'extraction'
| 'parsing'
| 'uploading';
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
/**
* Phase progress information
* Tracks the status of each processing phase
*/
export interface PhaseProgress {
/** Name of the phase */
name: ProcessingPhase;
/** Current status of this phase */
status: 'pending' | 'in_progress' | 'completed' | 'error';
/** When phase started processing (ISO 8601 string) */
startedAt?: string;
/** When phase completed (ISO 8601 string) */
completedAt?: string;
/** Error message if phase failed */
error?: string;
/** Name of the phase */
name: ProcessingPhase;
/** Current status of this phase */
status: 'pending' | 'in_progress' | 'completed' | 'error';
/** When phase started processing (ISO 8601 string) */
startedAt?: string;
/** When phase completed (ISO 8601 string) */
completedAt?: string;
/** Error message if phase failed */
error?: string;
}
/**
@@ -55,135 +47,135 @@ export interface PhaseProgress {
* Contains all outputs from the processing pipeline
*/
export interface ProcessingResults {
/** Extracted text from Instagram */
extractedText?: string;
/** Thumbnail URL or data URL */
thumbnail?: string | null;
/** Parsed recipe object */
recipe?: any;
/** Tandoor recipe ID */
tandoorRecipeId?: number;
/** Tandoor recipe URL (constructed from ID) */
tandoorUrl?: string;
/** Extracted text from Instagram */
extractedText?: string;
/** Thumbnail URL or data URL */
thumbnail?: string | null;
/** Parsed recipe object */
recipe?: any;
/** Tandoor recipe ID */
tandoorRecipeId?: number;
/** Tandoor recipe URL (constructed from ID) */
tandoorUrl?: string;
}
/**
* Queue item representing a single Instagram URL processing job
*/
export interface QueueItem {
/** Unique identifier (UUID) */
id: string;
/** Instagram URL to process */
url: string;
/** Current status of the item */
status: QueueItemStatus;
// Phase tracking
/** Current processing phase (only set when status is in_progress) */
currentPhase?: ProcessingPhase;
/** Array of all phases with their progress status */
phases: PhaseProgress[];
// Timestamps
/** When item was added to queue (ISO 8601 string) */
enqueuedAt: string;
/** Alias for enqueuedAt (frontend uses this) */
createdAt: string;
/** When processing started (ISO 8601 string) */
startedAt?: string;
/** When processing completed (ISO 8601 string) */
completedAt?: string;
/** Last update timestamp (ISO 8601 string) */
updatedAt?: string;
// Results - wrapped in results object
/** Processing results container */
results?: ProcessingResults;
// Legacy direct properties (kept for transition period)
/** @deprecated Use results.extractedText instead */
extractedText?: string;
/** @deprecated Use results.thumbnail instead */
thumbnail?: string | null;
/** @deprecated Use results.recipe instead */
recipe?: any;
/** @deprecated Use results.tandoorRecipeId instead */
tandoorRecipeId?: number;
// Progress tracking
/** User-facing log messages */
logs: string[];
/** All SSE progress events received */
progressEvents: ProgressEvent[];
// Error handling
/** Error details if processing failed */
error?: {
/** Phase where error occurred */
phase: ProcessingPhase;
/** Error message */
message: string;
/** Whether error is recoverable (can retry) */
recoverable: boolean;
/** When error occurred (ISO 8601 string) */
timestamp: string;
};
// Retry tracking
/** Number of times this item has been retried */
retryCount: number;
/** Maximum number of retries allowed */
maxRetries: number;
/** Unique identifier (UUID) */
id: string;
/** Instagram URL to process */
url: string;
/** Current status of the item */
status: QueueItemStatus;
// Phase tracking
/** Current processing phase (only set when status is in_progress) */
currentPhase?: ProcessingPhase;
/** Array of all phases with their progress status */
phases: PhaseProgress[];
// Timestamps
/** When item was added to queue (ISO 8601 string) */
enqueuedAt: string;
/** Alias for enqueuedAt (frontend uses this) */
createdAt: string;
/** When processing started (ISO 8601 string) */
startedAt?: string;
/** When processing completed (ISO 8601 string) */
completedAt?: string;
/** Last update timestamp (ISO 8601 string) */
updatedAt?: string;
// Results - wrapped in results object
/** Processing results container */
results?: ProcessingResults;
// Legacy direct properties (kept for transition period)
/** @deprecated Use results.extractedText instead */
extractedText?: string;
/** @deprecated Use results.thumbnail instead */
thumbnail?: string | null;
/** @deprecated Use results.recipe instead */
recipe?: any;
/** @deprecated Use results.tandoorRecipeId instead */
tandoorRecipeId?: number;
// Progress tracking
/** User-facing log messages */
logs: string[];
/** All SSE progress events received */
progressEvents: ProgressEvent[];
// Error handling
/** Error details if processing failed */
error?: {
/** Phase where error occurred */
phase: ProcessingPhase;
/** Error message */
message: string;
/** Whether error is recoverable (can retry) */
recoverable: boolean;
/** When error occurred (ISO 8601 string) */
timestamp: string;
};
// Retry tracking
/** Number of times this item has been retried */
retryCount: number;
/** Maximum number of retries allowed */
maxRetries: number;
}
/**
* Update notification sent to queue subscribers
*/
export interface QueueStatusUpdate {
/** Type of update */
type: 'status_change' | 'progress' | 'phase_complete';
/** ID of the item that was updated */
itemId: string;
/** New status of the item */
status: QueueItemStatus;
/** When update occurred (ISO 8601 string) */
timestamp: string;
/** URL of the item */
url?: string;
// Phase information
/** Current phase (if status is in_progress) */
phase?: ProcessingPhase;
/** Full phase progress array */
progress?: PhaseProgress[];
// Results
/** Processing results object */
results?: ProcessingResults;
// Error
/** Error information */
error?: any;
/** Additional data related to the update (legacy) */
data?: any;
/** Type of update */
type: 'status_change' | 'progress' | 'phase_complete';
/** ID of the item that was updated */
itemId: string;
/** New status of the item */
status: QueueItemStatus;
/** When update occurred (ISO 8601 string) */
timestamp: string;
/** URL of the item */
url?: string;
// Phase information
/** Current phase (if status is in_progress) */
phase?: ProcessingPhase;
/** Full phase progress array */
progress?: PhaseProgress[];
// Results
/** Processing results object */
results?: ProcessingResults;
// Error
/** Error information */
error?: any;
/** Additional data related to the update (legacy) */
data?: any;
}
/**

View File

@@ -1,194 +1,202 @@
import fs from 'fs';
import path from 'path';
import { getBrowser } from './browser';
import { env } from '$env/dynamic/private';
import { logError } from './utils/logger';
export interface SchedulerConfig {
enabled: boolean;
intervalMinutes: number;
}
interface SchedulerState {
intervalId: NodeJS.Timeout | null;
lastRenewalTime: number | null;
isRenewing: boolean;
}
const state: SchedulerState = {
intervalId: null,
lastRenewalTime: null,
isRenewing: false
};
/**
* Get scheduler configuration from environment variables
*/
function getConfig(): SchedulerConfig {
const enabled = env.AUTH_SCHEDULER_ENABLED === 'true';
let intervalMinutes = parseInt(env.AUTH_SCHEDULER_INTERVAL_MINUTES || '720', 10);
if (isNaN(intervalMinutes) || intervalMinutes < 5) {
console.warn(
`[Scheduler] Invalid or too short interval '${env.AUTH_SCHEDULER_INTERVAL_MINUTES}'. Defaulting to 720 minutes.`
);
intervalMinutes = 720;
}
return {
enabled,
intervalMinutes
};
}
/**
* Resolve authentication storage path
*/
function resolveAuthPath(): string {
const authPathDocker = '/app/secrets/auth.json';
const authPathLocal = './secrets/auth.json';
if (fs.existsSync(authPathDocker)) {
return authPathDocker;
}
if (fs.existsSync(authPathLocal)) {
return authPathLocal;
}
// Default to local path if neither exists yet
return authPathLocal;
}
/**
* Renew Instagram authentication by loading existing auth and refreshing the session
* Inspired by gen-auth.js - reuses existing stored credentials without manual input
*/
async function renewInstagramAuth(): Promise<boolean> {
if (state.isRenewing) {
console.log('[Scheduler] Auth renewal already in progress, skipping');
return false;
}
const authPath = resolveAuthPath();
if (!fs.existsSync(authPath)) {
console.warn('[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.');
return false;
}
state.isRenewing = true;
let context = null;
let page = null;
try {
console.log('[Scheduler] Starting Instagram authentication renewal...');
console.log(`[Scheduler] Loading existing auth from: ${authPath}`);
const browser = await getBrowser();
// Load existing authentication state
context = await browser.newContext({ storageState: authPath });
page = await context.newPage();
// Navigate to Instagram homepage - the existing auth will be used automatically
await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
// Wait for the "Home" icon to appear (indicates successful login)
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 30000 });
console.log('[Scheduler] Successfully authenticated with Instagram');
} catch (e) {
logError('[Scheduler] Home icon not found - session may be expired or invalid', e);
return false;
}
// Save the refreshed authentication state
const authDir = path.dirname(authPath);
// Ensure directory exists
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
// Update auth.json with refreshed session
await context.storageState({ path: authPath });
state.lastRenewalTime = Date.now();
console.log(`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`);
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
return true;
} catch (error) {
logError('[Scheduler] Instagram authentication renewal failed', error);
return false;
} finally {
if (page) {
await page.close().catch(() => {});
}
if (context) {
await context.close().catch(() => {});
}
state.isRenewing = false;
}
}
/**
* Start the authentication renewal scheduler
*/
export async function startScheduler(): Promise<void> {
const config = getConfig();
if (!config.enabled) {
console.log('[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)');
return;
}
if (state.intervalId !== null) {
console.warn('[Scheduler] Scheduler is already running');
return;
}
const intervalMs = config.intervalMinutes * 60 * 1000;
console.log(`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`);
// Schedule periodic renewals
state.intervalId = setInterval(async () => {
await renewInstagramAuth();
}, intervalMs);
// Ensure interval is not blocking (set it as unreferenceable so it doesn't keep the process alive)
if (state.intervalId.unref) {
state.intervalId.unref();
}
// Optional: Perform initial renewal on startup (uncomment to enable)
// await renewInstagramAuth();
}
/**
* Stop the authentication renewal scheduler
*/
export async function stopScheduler(): Promise<void> {
if (state.intervalId === null) {
console.log('[Scheduler] Scheduler is not running');
return;
}
console.log('[Scheduler] Stopping authentication scheduler...');
clearInterval(state.intervalId);
state.intervalId = null;
}
/**
* Get scheduler status information
*/
export function getSchedulerStatus() {
return {
running: state.intervalId !== null,
lastRenewalTime: state.lastRenewalTime ? new Date(state.lastRenewalTime).toISOString() : null,
isRenewing: state.isRenewing,
config: getConfig()
};
}
import fs from 'fs';
import path from 'path';
import { getBrowser } from './browser';
import { env } from '$env/dynamic/private';
import { logError } from './utils/logger';
export interface SchedulerConfig {
enabled: boolean;
intervalMinutes: number;
}
interface SchedulerState {
intervalId: NodeJS.Timeout | null;
lastRenewalTime: number | null;
isRenewing: boolean;
}
const state: SchedulerState = {
intervalId: null,
lastRenewalTime: null,
isRenewing: false
};
/**
* Get scheduler configuration from environment variables
*/
function getConfig(): SchedulerConfig {
const enabled = env.AUTH_SCHEDULER_ENABLED === 'true';
let intervalMinutes = parseInt(env.AUTH_SCHEDULER_INTERVAL_MINUTES || '720', 10);
if (isNaN(intervalMinutes) || intervalMinutes < 5) {
console.warn(
`[Scheduler] Invalid or too short interval '${env.AUTH_SCHEDULER_INTERVAL_MINUTES}'. Defaulting to 720 minutes.`
);
intervalMinutes = 720;
}
return {
enabled,
intervalMinutes
};
}
/**
* Resolve authentication storage path
*/
function resolveAuthPath(): string {
const authPathDocker = '/app/secrets/auth.json';
const authPathLocal = './secrets/auth.json';
if (fs.existsSync(authPathDocker)) {
return authPathDocker;
}
if (fs.existsSync(authPathLocal)) {
return authPathLocal;
}
// Default to local path if neither exists yet
return authPathLocal;
}
/**
* Renew Instagram authentication by loading existing auth and refreshing the session
* Inspired by gen-auth.js - reuses existing stored credentials without manual input
*/
async function renewInstagramAuth(): Promise<boolean> {
if (state.isRenewing) {
console.log('[Scheduler] Auth renewal already in progress, skipping');
return false;
}
const authPath = resolveAuthPath();
if (!fs.existsSync(authPath)) {
console.warn(
'[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.'
);
return false;
}
state.isRenewing = true;
let context = null;
let page = null;
try {
console.log('[Scheduler] Starting Instagram authentication renewal...');
console.log(`[Scheduler] Loading existing auth from: ${authPath}`);
const browser = await getBrowser();
// Load existing authentication state
context = await browser.newContext({ storageState: authPath });
page = await context.newPage();
// Navigate to Instagram homepage - the existing auth will be used automatically
await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
// Wait for the "Home" icon to appear (indicates successful login)
try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 30000 });
console.log('[Scheduler] Successfully authenticated with Instagram');
} catch (e) {
logError('[Scheduler] Home icon not found - session may be expired or invalid', e);
return false;
}
// Save the refreshed authentication state
const authDir = path.dirname(authPath);
// Ensure directory exists
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
// Update auth.json with refreshed session
await context.storageState({ path: authPath });
state.lastRenewalTime = Date.now();
console.log(
`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`
);
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
return true;
} catch (error) {
logError('[Scheduler] Instagram authentication renewal failed', error);
return false;
} finally {
if (page) {
await page.close().catch(() => {});
}
if (context) {
await context.close().catch(() => {});
}
state.isRenewing = false;
}
}
/**
* Start the authentication renewal scheduler
*/
export async function startScheduler(): Promise<void> {
const config = getConfig();
if (!config.enabled) {
console.log(
'[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)'
);
return;
}
if (state.intervalId !== null) {
console.warn('[Scheduler] Scheduler is already running');
return;
}
const intervalMs = config.intervalMinutes * 60 * 1000;
console.log(
`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`
);
// Schedule periodic renewals
state.intervalId = setInterval(async () => {
await renewInstagramAuth();
}, intervalMs);
// Ensure interval is not blocking (set it as unreferenceable so it doesn't keep the process alive)
if (state.intervalId.unref) {
state.intervalId.unref();
}
// Optional: Perform initial renewal on startup (uncomment to enable)
// await renewInstagramAuth();
}
/**
* Stop the authentication renewal scheduler
*/
export async function stopScheduler(): Promise<void> {
if (state.intervalId === null) {
console.log('[Scheduler] Scheduler is not running');
return;
}
console.log('[Scheduler] Stopping authentication scheduler...');
clearInterval(state.intervalId);
state.intervalId = null;
}
/**
* Get scheduler status information
*/
export function getSchedulerStatus() {
return {
running: state.intervalId !== null,
lastRenewalTime: state.lastRenewalTime ? new Date(state.lastRenewalTime).toISOString() : null,
isRenewing: state.isRenewing,
config: getConfig()
};
}

View File

@@ -1,12 +1,12 @@
import { env } from '$env/dynamic/private';
/**
* Server-side environment configuration for Tandoor integration
* These variables should be set in your .env file or as environment variables
*/
export const tandoorConfig = {
enabled: env.TANDOOR_ENABLED === 'true',
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
token: env.TANDOOR_TOKEN || null
};
import { env } from '$env/dynamic/private';
/**
* Server-side environment configuration for Tandoor integration
* These variables should be set in your .env file or as environment variables
*/
export const tandoorConfig = {
enabled: env.TANDOOR_ENABLED === 'true',
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
token: env.TANDOOR_TOKEN || null
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
/**
* Logging Utilities
*
*
* Provides error serialization and structured logging utilities to prevent
* [object Object] logs in production. All functions handle circular references
* and properly serialize Error objects with their properties.
*
*
* Features:
* - Error serialization with stack traces
* - Circular reference detection and handling
@@ -15,10 +15,10 @@
/**
* Serializes an error object to a JSON string.
* Handles both Error instances and plain objects.
*
*
* @param error - Error object or unknown value to serialize
* @returns JSON string representation of the error
*
*
* @example
* ```typescript
* const err = new Error('Something went wrong');
@@ -27,34 +27,34 @@
* ```
*/
export function serializeError(error: unknown): string {
if (error instanceof Error) {
const errorObject: Record<string, any> = {
name: error.name,
message: error.message,
stack: error.stack
};
// Add custom properties from the error object
for (const key of Object.keys(error)) {
if (!(key in errorObject)) {
errorObject[key] = (error as any)[key];
}
}
return JSON.stringify(errorObject, null, 2);
}
return JSON.stringify(error, null, 2);
if (error instanceof Error) {
const errorObject: Record<string, any> = {
name: error.name,
message: error.message,
stack: error.stack
};
// Add custom properties from the error object
for (const key of Object.keys(error)) {
if (!(key in errorObject)) {
errorObject[key] = (error as any)[key];
}
}
return JSON.stringify(errorObject, null, 2);
}
return JSON.stringify(error, null, 2);
}
/**
* Serializes an object to a JSON string with circular reference handling.
* Prevents "Converting circular structure to JSON" errors.
*
*
* @param obj - Object to serialize
* @param maxDepth - Maximum depth for nested objects (default: 10)
* @returns JSON string representation of the object
*
*
* @example
* ```typescript
* const circular: any = { a: 1 };
@@ -64,28 +64,28 @@ export function serializeError(error: unknown): string {
* ```
*/
export function serializeObject(obj: unknown, maxDepth: number = 10): string {
const seen = new WeakSet();
const replacer = (key: string, value: any): any => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
};
return JSON.stringify(obj, replacer, 2);
const seen = new WeakSet();
const replacer = (key: string, value: any): any => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
};
return JSON.stringify(obj, replacer, 2);
}
/**
* Logs an error to console.error with proper serialization.
* Convenience wrapper around serializeError().
*
*
* @param prefix - Log prefix (e.g., '[ComponentName]')
* @param error - Error object or unknown value to log
*
*
* @example
* ```typescript
* try {
@@ -96,23 +96,23 @@ export function serializeObject(obj: unknown, maxDepth: number = 10): string {
* ```
*/
export function logError(prefix: string, error: unknown): void {
if (error instanceof Error) {
console.error(prefix, error.message);
if (error.stack) {
console.error('Stack:', error.stack);
}
} else {
console.error(prefix, serializeError(error));
}
if (error instanceof Error) {
console.error(prefix, error.message);
if (error.stack) {
console.error('Stack:', error.stack);
}
} else {
console.error(prefix, serializeError(error));
}
}
/**
* Logs an object to console.log with proper serialization.
* Handles circular references automatically.
*
*
* @param prefix - Log prefix (e.g., '[ComponentName]')
* @param obj - Object to log
*
*
* @example
* ```typescript
* const config = { url: 'https://example.com', timeout: 5000 };
@@ -120,5 +120,5 @@ export function logError(prefix: string, error: unknown): void {
* ```
*/
export function logObject(prefix: string, obj: unknown): void {
console.log(prefix, serializeObject(obj));
console.log(prefix, serializeObject(obj));
}

View File

@@ -1,6 +1,6 @@
/**
* Instagram URL Validation Utility
*
*
* Validates that a URL is from Instagram's domain and uses HTTPS.
* Accepts all Instagram URL formats (posts, reels, IGTV, etc.).
*/
@@ -12,23 +12,23 @@ export interface ValidationResult {
/**
* Validate Instagram URL
*
*
* Accepts:
* - https://instagram.com/p/{post-id}
* - https://www.instagram.com/p/{post-id}
* - https://instagram.com/reel/{reel-id}
* - https://instagram.com/tv/{tv-id}
* - Any Instagram URL with query parameters
*
*
* Rejects:
* - Non-HTTPS URLs (http://)
* - Non-Instagram domains
* - Invalid URL format
* - Subdomains other than www
*
*
* @param url - The URL to validate
* @returns Validation result with valid flag and optional error message
*
*
* @example
* ```typescript
* const result = validateInstagramUrl('https://instagram.com/reel/ABC123?utm_source=share');

View File

@@ -1,12 +1,12 @@
/**
* DEPRECATED: Legacy synchronous extraction endpoint
*
*
* This endpoint is deprecated and will be removed in a future version.
* Use the new async queue system instead:
*
*
* POST /api/queue - Submit URL for async processing
* GET /api/queue/stream - Real-time progress updates via SSE
*
*
* Migration Guide: /docs/MIGRATION.md
*/
@@ -31,7 +31,7 @@ export const POST: RequestHandler = async ({ request }) => {
removedIn: 'v2.0.0'
}
},
{
{
status: 410, // 410 Gone - resource no longer available
headers: {
'X-Deprecated': 'true',
@@ -40,4 +40,4 @@ export const POST: RequestHandler = async ({ request }) => {
}
}
);
};
};

View File

@@ -1,11 +1,11 @@
/**
* Health Check API Endpoint
*
*
* Provides status information about critical application services:
* - Queue processing status
* - Queue statistics (pending, in_progress, etc.)
* - Server uptime information
*
*
* Used for monitoring and debugging queue processor functionality.
*/
@@ -14,48 +14,51 @@ import { queueManager } from '$lib/server/queue/QueueManager';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
export const GET = async () => {
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter(item => item.status === 'pending').length,
in_progress: allItems.filter(item => item.status === 'in_progress').length,
success: allItems.filter(item => item.status === 'success').length,
error: allItems.filter(item => item.status === 'error').length,
unhealthy: allItems.filter(item => item.status === 'unhealthy').length
};
const stats = {
total: allItems.length
};
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter((item) => item.status === 'pending').length,
in_progress: allItems.filter((item) => item.status === 'in_progress').length,
success: allItems.filter((item) => item.status === 'success').length,
error: allItems.filter((item) => item.status === 'error').length,
unhealthy: allItems.filter((item) => item.status === 'unhealthy').length
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
const stats = {
total: allItems.length
};
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json({
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
}, { status: 500 });
}
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json(
{
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
},
{ status: 500 }
);
}
};

View File

@@ -10,21 +10,27 @@ export async function GET() {
const isHealthy = await checkLLMHealth();
if (isHealthy) {
return json({
status: 'healthy',
message: 'LLM service is accessible'
return json({
status: 'healthy',
message: 'LLM service is accessible'
});
} else {
return json({
status: 'unhealthy',
message: 'LLM service is not accessible'
}, { status: 503 });
return json(
{
status: 'unhealthy',
message: 'LLM service is not accessible'
},
{ status: 503 }
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return json({
status: 'error',
message: errorMessage
}, { status: 500 });
return json(
{
status: 'error',
message: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
/**
* Push Notification Subscription API
*
*
* Handles web push notification subscription/unsubscription
* for queue processing updates.
*/
@@ -11,9 +11,9 @@ import type { RequestHandler } from './$types.js';
/**
* Subscribe to push notifications
*
*
* POST /api/notifications/subscribe
*
*
* Body:
* {
* "subscription": {
@@ -27,87 +27,70 @@ import type { RequestHandler } from './$types.js';
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { subscription, clientId } = await request.json();
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json(
{ error: 'Failed to subscribe to notifications' },
{ status: 500 }
);
}
try {
const { subscription, clientId } = await request.json();
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json({ error: 'Invalid subscription object' }, { status: 400 });
}
if (!clientId || typeof clientId !== 'string') {
return json({ error: 'Client ID is required' }, { status: 400 });
}
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json({ error: 'Failed to subscribe to notifications' }, { status: 500 });
}
};
/**
* Unsubscribe from push notifications
*
*
* DELETE /api/notifications/subscribe
*
*
* Body:
* {
* "clientId": "unique-client-id"
* }
*/
export const DELETE: RequestHandler = async ({ request }) => {
try {
const { clientId } = await request.json();
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json(
{ error: 'Failed to unsubscribe from notifications' },
{ status: 500 }
);
}
};
try {
const { clientId } = await request.json();
if (!clientId || typeof clientId !== 'string') {
return json({ error: 'Client ID is required' }, { status: 400 });
}
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json({ error: 'Failed to unsubscribe from notifications' }, { status: 500 });
}
};

View File

@@ -1,6 +1,6 @@
/**
* Test Push Notification API
*
*
* Allows manual testing of push notifications with different payloads.
* Sends notification to all subscribed clients.
*/
@@ -11,71 +11,69 @@ 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 }
);
}
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 });
}
};

View File

@@ -1,6 +1,6 @@
/**
* VAPID Public Key API
*
*
* Returns the public key for web push notifications.
* Required by browsers to create push subscriptions.
*/
@@ -11,9 +11,9 @@ import type { RequestHandler } from './$types.js';
/**
* Get VAPID public key
*
*
* GET /api/notifications/vapid-key
*
*
* Response:
* {
* "publicKey": "BDummyPublicKeyForDevelopment",
@@ -21,26 +21,19 @@ import type { RequestHandler } from './$types.js';
* }
*/
export const GET: RequestHandler = async () => {
try {
const publicKey = pushNotificationService.getPublicVapidKey();
if (!publicKey) {
return json(
{ error: 'VAPID public key not configured' },
{ status: 503 }
);
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
};
try {
const publicKey = pushNotificationService.getPublicVapidKey();
if (!publicKey) {
return json({ error: 'VAPID public key not configured' }, { status: 503 });
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json({ error: 'Failed to get VAPID public key' }, { status: 500 });
}
};

View File

@@ -1,6 +1,6 @@
/**
* Queue API Endpoints
*
*
* Provides HTTP interface for queue operations:
* - POST /api/queue - Enqueue Instagram URL for processing
* - GET /api/queue - List all queue items with optional status filtering
@@ -15,135 +15,133 @@ import type { RequestHandler } from './$types';
/**
* POST /api/queue - Enqueue Instagram URL
*
*
* Body: { url: string }
* Returns: { id: string, url: string, status: string, enqueuedAt: string }
*
*
* Validates Instagram URL format and enqueues for processing.
* Returns 400 for invalid URLs, 500 for server errors.
*/
export const POST: RequestHandler = async ({ request }) => {
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
throw new ValidationError('Invalid JSON in request body');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (error) {
return handleApiError(error);
}
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
throw new ValidationError('Invalid JSON in request body');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (error) {
return handleApiError(error);
}
};
/**
* GET /api/queue - List queue items
*
*
* Query params:
* - status?: string - Filter by status (pending, in_progress, success, unhealthy, error)
* - limit?: number - Maximum items to return (default: 50, max: 200)
* - offset?: number - Pagination offset (default: 0)
*
*
* Returns: { items: QueueItem[], total: number, hasMore: boolean }
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const searchParams = url.searchParams;
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('Limit cannot exceed 200');
}
limit = parsedLimit;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
throw new ValidationError('Offset must be a non-negative integer');
}
offset = parsedOffset;
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
throw new ValidationError(
`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
);
}
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Apply status filter
if (statusFilter) {
items = items.filter(item => item.status === statusFilter);
}
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = (offset + limit) < items.length;
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (error) {
return handleApiError(error);
}
};
try {
const searchParams = url.searchParams;
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('Limit cannot exceed 200');
}
limit = parsedLimit;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
throw new ValidationError('Offset must be a non-negative integer');
}
offset = parsedOffset;
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
throw new ValidationError(
`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
);
}
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Apply status filter
if (statusFilter) {
items = items.filter((item) => item.status === statusFilter);
}
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = offset + limit < items.length;
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* Individual Queue Item API Endpoints
*
*
* Provides HTTP interface for individual queue item operations:
* - GET /api/queue/[id] - Get specific queue item details
* - DELETE /api/queue/[id] - Remove queue item
@@ -14,84 +14,80 @@ import type { RequestHandler } from './$types';
/**
* GET /api/queue/[id] - Get queue item by ID
*
*
* Returns full queue item details including progress events and results.
* Returns 404 if item not found, 400 for invalid ID format.
*/
export const GET: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(error);
}
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(error);
}
};
/**
* DELETE /api/queue/[id] - Remove queue item
*
*
* Removes an item from the queue.
* Returns 404 if item not found, 400 for invalid ID format,
* 409 if item is currently being processed.
*/
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError(
'Cannot delete item that is currently being processed'
);
}
// Remove the item
const success = queueManager.remove(id);
return json({
success,
message: 'Queue item removed successfully'
});
} catch (error) {
return handleApiError(error);
}
};
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError('Cannot delete item that is currently being processed');
}
// Remove the item
const success = queueManager.remove(id);
return json({
success,
message: 'Queue item removed successfully'
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* Queue Item Retry API Endpoint
*
*
* Provides HTTP interface for retrying failed queue items:
* - POST /api/queue/[id]/retry - Retry failed/unhealthy queue item
*/
@@ -13,58 +13,57 @@ import type { RequestHandler } from './$types';
/**
* POST /api/queue/[id]/retry - Retry queue item
*
*
* Resets a failed or unhealthy queue item to pending status for reprocessing.
* Only items with status 'error' or 'unhealthy' can be retried.
*
*
* Returns the updated queue item on success.
* Returns 404 if item not found, 400 for invalid operations, 409 for wrong status.
*/
export const POST: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
);
}
// Retry the item
const retryResult = queueManager.retry(id);
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
throw new Error('Failed to retry queue item');
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (error) {
return handleApiError(error);
}
};
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('Queue item ID is required');
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
);
}
// Retry the item
const retryResult = queueManager.retry(id);
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
throw new Error('Failed to retry queue item');
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* Queue SSE Stream API Endpoint
*
*
* Provides Server-Sent Events stream for real-time queue updates:
* - GET /api/queue/stream - Stream queue status updates
*/
@@ -11,209 +11,209 @@ import type { QueueStatusUpdate } from '$lib/server/queue/types';
/**
* GET /api/queue/stream - Server-Sent Events stream for queue updates
*
*
* Returns a continuous stream of queue status updates in SSE format.
* Supports optional query parameters:
* - ?id={queue-item-id} - Stream updates only for specific item
* - ?status={status} - Stream updates only for items with specific status
*
*
* SSE Event Format:
* - event: queue-update
* - data: JSON string with QueueStatusUpdate object
*
*
* Connection is kept alive until client disconnects.
*/
export const GET: RequestHandler = async ({ url, request }) => {
const searchParams = url.searchParams;
const itemIdFilter = searchParams.get('id');
const statusFilter = searchParams.get('status');
// Validate status filter if provided
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// Validate item ID filter if provided
if (itemIdFilter) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(itemIdFilter)) {
return new Response('Invalid queue item ID format', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
console.log('[SSE] Cleaning up stream connection');
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// Send current queue state as initial data
try {
const currentItems = queueManager.getAll();
let filteredItems = currentItems;
// Apply filters
if (itemIdFilter) {
filteredItems = currentItems.filter(item => item.id === itemIdFilter);
}
if (statusFilter) {
filteredItems = filteredItems.filter(item => item.status === statusFilter);
}
// Send initial state for each matching item
for (const item of filteredItems) {
if (isClosed) break; // Stop if stream was closed
const update: QueueStatusUpdate = {
type: 'status_change',
itemId: item.id,
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
progress: item.phases,
results: item.results,
error: item.error
};
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// Apply filters
let shouldSend = true;
if (itemIdFilter && update.itemId !== itemIdFilter) {
shouldSend = false;
}
if (statusFilter && update.status !== statusFilter) {
shouldSend = false;
}
if (shouldSend) {
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
safeEnqueue(controller, sseMessage);
}
});
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};
const searchParams = url.searchParams;
const itemIdFilter = searchParams.get('id');
const statusFilter = searchParams.get('status');
// Validate status filter if provided
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// Validate item ID filter if provided
if (itemIdFilter) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(itemIdFilter)) {
return new Response('Invalid queue item ID format', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
console.log('[SSE] Cleaning up stream connection');
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// Send current queue state as initial data
try {
const currentItems = queueManager.getAll();
let filteredItems = currentItems;
// Apply filters
if (itemIdFilter) {
filteredItems = currentItems.filter((item) => item.id === itemIdFilter);
}
if (statusFilter) {
filteredItems = filteredItems.filter((item) => item.status === statusFilter);
}
// Send initial state for each matching item
for (const item of filteredItems) {
if (isClosed) break; // Stop if stream was closed
const update: QueueStatusUpdate = {
type: 'status_change',
itemId: item.id,
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
progress: item.phases,
results: item.results,
error: item.error
};
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// Apply filters
let shouldSend = true;
if (itemIdFilter && update.itemId !== itemIdFilter) {
shouldSend = false;
}
if (statusFilter && update.status !== statusFilter) {
shouldSend = false;
}
if (shouldSend) {
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
safeEnqueue(controller, sseMessage);
}
});
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};

View File

@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
}
import { json } from '@sveltejs/kit';
import { tandoorConfig } from '$lib/server/tandoor-config';
export async function GET() {
return json({ ...tandoorConfig, token: '' });
}

View File

@@ -1,43 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export const POST: RequestHandler = async ({ request }) => {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export const POST: RequestHandler = async ({ request }) => {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
};

View File

@@ -6,7 +6,7 @@ import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});

View File

@@ -7,287 +7,284 @@ import { build, files, version } from '$service-worker';
declare let self: ServiceWorkerGlobalScope;
// Create a unique cache name for this deployment
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
const ASSETS = [
...build, // the app itself
...files // everything in `static`
...build, // the app itself
...files // everything in `static`
];
// Global error handlers (preserve existing)
self.addEventListener('error', (event) => {
console.error('[SW] Global error:', event.error);
console.error('[SW] Error details:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
});
console.error('[SW] Global error:', event.error);
console.error('[SW] Error details:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
});
});
self.addEventListener('unhandledrejection', (event) => {
console.error('[SW] Unhandled promise rejection:', event.reason);
event.preventDefault(); // Prevent default browser behavior
console.error('[SW] Unhandled promise rejection:', event.reason);
event.preventDefault(); // Prevent default browser behavior
});
console.log('[SW] Service worker script loading...');
// Install event - cache all assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
console.log(`[SW] Cached ${ASSETS.length} assets`);
}
console.log('[SW] Installing service worker...');
event.waitUntil(addFilesToCache());
async function addFilesToCache() {
const cache = await caches.open(CACHE);
await cache.addAll(ASSETS);
console.log(`[SW] Cached ${ASSETS.length} assets`);
}
event.waitUntil(addFilesToCache());
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
console.log('[SW] Deleting old cache:', key);
await caches.delete(key);
}
}
}
console.log('[SW] Activating service worker...');
event.waitUntil(deleteOldCaches());
async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
console.log('[SW] Deleting old cache:', key);
await caches.delete(key);
}
}
}
event.waitUntil(deleteOldCaches());
});
// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
if (event.request.method !== 'GET') return;
// ignore POST requests etc
if (event.request.method !== 'GET') return;
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
event.respondWith(respond());
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
event.respondWith(respond());
});
// Push notification handling
self.addEventListener('push', (event) => {
console.log('[SW] Push event received:', event);
if (!event.data) {
console.log('[SW] Push event but no data');
return;
}
console.log('[SW] Push event received:', event);
let data;
try {
data = event.data.json();
} catch (e) {
console.error('[SW] Failed to parse push data:', e);
return;
}
if (!event.data) {
console.log('[SW] Push event but no data');
return;
}
console.log('[SW] Push data:', data);
let data;
try {
data = event.data.json();
} catch (e) {
console.error('[SW] Failed to parse push data:', e);
return;
}
const options: NotificationOptions = {
body: data.body || 'Recipe processing update',
icon: '/favicon.png',
badge: '/favicon.png',
data: data,
requireInteraction: data.requireInteraction || false,
silent: false,
tag: data.tag || 'recipe-update',
timestamp: Date.now(),
actions: []
};
console.log('[SW] Push data:', data);
// Add actions based on notification type
if (data.type === 'success' && data.itemId) {
options.actions = [
{
action: 'view',
title: 'View Recipe',
icon: '/favicon.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
];
} else if (data.type === 'error' && data.itemId) {
options.actions = [
{
action: 'retry',
title: 'Retry',
icon: '/favicon.png'
},
{
action: 'view',
title: 'View Details'
}
];
}
const options: NotificationOptions = {
body: data.body || 'Recipe processing update',
icon: '/favicon.png',
badge: '/favicon.png',
data: data,
requireInteraction: data.requireInteraction || false,
silent: false,
tag: data.tag || 'recipe-update',
timestamp: Date.now(),
actions: []
};
const title = data.title || getNotificationTitle(data.type, data);
// Add actions based on notification type
if (data.type === 'success' && data.itemId) {
options.actions = [
{
action: 'view',
title: 'View Recipe',
icon: '/favicon.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
];
} else if (data.type === 'error' && data.itemId) {
options.actions = [
{
action: 'retry',
title: 'Retry',
icon: '/favicon.png'
},
{
action: 'view',
title: 'View Details'
}
];
}
event.waitUntil(
self.registration.showNotification(title, options)
);
const title = data.title || getNotificationTitle(data.type, data);
event.waitUntil(self.registration.showNotification(title, options));
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click received:', event);
event.notification.close();
console.log('[SW] Notification click received:', event);
const data = event.notification.data;
const action = event.action;
event.notification.close();
let url = '/';
const data = event.notification.data;
const action = event.action;
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
let url = '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientsList) => {
// Check if there's already a window/tab open
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
data: data
});
});
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
// Check if there's already a window/tab open
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
data: data
});
});
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Handle notification close
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event);
// Track notification dismissals if needed
const data = event.notification.data;
if (data?.analytics) {
// Could send analytics event here
console.log('[SW] Notification dismissed:', data);
}
console.log('[SW] Notification closed:', event);
// Track notification dismissals if needed
const data = event.notification.data;
if (data?.analytics) {
// Could send analytics event here
console.log('[SW] Notification dismissed:', data);
}
});
// Background sync for retry operations
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'retry-queue-item') {
event.waitUntil(handleRetrySync());
}
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'retry-queue-item') {
event.waitUntil(handleRetrySync());
}
});
// Helper functions
function getNotificationTitle(type: string, data: any): string {
switch (type) {
case 'success':
return data.recipeName
? `✅ Recipe Ready: ${data.recipeName}`
: '✅ Recipe extraction complete';
case 'error':
return '❌ Recipe extraction failed';
case 'progress':
return `🔄 Processing recipe...`;
default:
return '📱 InstaRecipe Update';
}
switch (type) {
case 'success':
return data.recipeName
? `✅ Recipe Ready: ${data.recipeName}`
: '✅ Recipe extraction complete';
case 'error':
return '❌ Recipe extraction failed';
case 'progress':
return `🔄 Processing recipe...`;
default:
return '📱 InstaRecipe Update';
}
}
async function handleRetrySync() {
try {
// Get retry items from IndexedDB or localStorage if needed
console.log('[SW] Handling retry sync');
// This could implement background retry logic
// For now, we'll let the main app handle retries
return Promise.resolve();
} catch (error) {
console.error('[SW] Retry sync failed:', error);
throw error;
}
try {
// Get retry items from IndexedDB or localStorage if needed
console.log('[SW] Handling retry sync');
// This could implement background retry logic
// For now, we'll let the main app handle retries
return Promise.resolve();
} catch (error) {
console.error('[SW] Retry sync failed:', error);
throw error;
}
}
// Message handling for communication with main app
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
console.log('[SW] Message received:', event.data);
const { type, data } = event.data;
const { type, data } = event.data;
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: '1.0.0' });
break;
case 'QUEUE_RETRY':
// Queue a background sync for retry
self.registration.sync.register('retry-queue-item');
break;
default:
console.log('[SW] Unknown message type:', type);
}
});
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: '1.0.0' });
break;
case 'QUEUE_RETRY':
// Queue a background sync for retry
self.registration.sync.register('retry-queue-item');
break;
default:
console.log('[SW] Unknown message type:', type);
}
});

View File

@@ -4,77 +4,77 @@ import * as logger from '$lib/server/utils/logger';
import { ValidationError, NotFoundError, ConflictError } from '$lib/server/api/errors';
describe('errorHandler logging', () => {
let logErrorSpy: any;
let logErrorSpy: any;
beforeEach(() => {
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
beforeEach(() => {
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
test('should use logError for standard errors', () => {
const error = new Error('Test error');
handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
});
test('should use logError for standard errors', () => {
const error = new Error('Test error');
test('should use logError for ValidationError', () => {
const error = new ValidationError('Invalid input');
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(400);
});
handleApiError(error);
test('should use logError for NotFoundError', () => {
const error = new NotFoundError('Resource not found');
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(404);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
});
test('should use logError for ConflictError', () => {
const error = new ConflictError('Resource conflict');
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(409);
});
test('should use logError for ValidationError', () => {
const error = new ValidationError('Invalid input');
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_VALIDATION',
message: 'Invalid input',
details: { field: 'email', reason: 'invalid format' }
};
handleApiError(complexError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', complexError);
});
const response = handleApiError(error);
test('should handle unknown error types', () => {
const unknownError = 'String error';
handleApiError(unknownError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', unknownError);
});
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(400);
});
test('logs should not use console.error directly', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('Test');
handleApiError(error);
// logError internally calls console.error, but handleApiError shouldn't call it directly
// We're checking that handleApiError uses logError, not console.error
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
consoleErrorSpy.mockRestore();
});
test('should use logError for NotFoundError', () => {
const error = new NotFoundError('Resource not found');
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(404);
});
test('should use logError for ConflictError', () => {
const error = new ConflictError('Resource conflict');
const response = handleApiError(error);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
expect(response.status).toBe(409);
});
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_VALIDATION',
message: 'Invalid input',
details: { field: 'email', reason: 'invalid format' }
};
handleApiError(complexError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', complexError);
});
test('should handle unknown error types', () => {
const unknownError = 'String error';
handleApiError(unknownError);
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', unknownError);
});
test('logs should not use console.error directly', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('Test');
handleApiError(error);
// logError internally calls console.error, but handleApiError shouldn't call it directly
// We're checking that handleApiError uses logError, not console.error
expect(logErrorSpy).toHaveBeenCalledWith('[API Error]', error);
consoleErrorSpy.mockRestore();
});
});

View File

@@ -5,15 +5,15 @@ import fs from 'fs';
describe('extraction.ts logging', () => {
let logErrorSpy: any;
beforeEach(() => {
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('should use logError for extraction failures', async () => {
// Trigger extraction error with invalid URL
try {
@@ -22,66 +22,61 @@ describe('extraction.ts logging', () => {
} catch (error) {
// Expected - extraction of invalid URL should fail
}
// logError should have been called during retry/error handling
expect(logErrorSpy).toHaveBeenCalled();
const calls = logErrorSpy.mock.calls;
expect(calls.length).toBeGreaterThan(0);
// Verify at least one call has the expected format
const errorCall = calls.find((call: any[]) =>
call[0]?.match(/\[.*\]/) && call[1] !== undefined
const errorCall = calls.find(
(call: any[]) => call[0]?.match(/\[.*\]/) && call[1] !== undefined
);
expect(errorCall).toBeDefined();
expect(errorCall[0]).toMatch(/\[.*\]/); // Has prefix like [Retry], [Extractor], etc
expect(errorCall[1]).toBeDefined(); // Has error object
});
test('logs should not contain [object Object]', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Trigger extraction error
try {
await extractTextAndThumbnail('https://invalid-url-that-will-fail.com/test');
} catch (e) {
// Expected
}
// Check all console.warn and console.error calls
const allCalls = [
...consoleWarnSpy.mock.calls,
...consoleErrorSpy.mock.calls
];
const allCalls = [...consoleWarnSpy.mock.calls, ...consoleErrorSpy.mock.calls];
const errorCalls = allCalls
.map(call => call.join(' '))
.filter(msg => msg.includes('[object Object]'));
.map((call) => call.join(' '))
.filter((msg) => msg.includes('[object Object]'));
expect(errorCalls).toHaveLength(0);
});
test('logError should serialize error objects properly', async () => {
// Create a mock error with complex structure
const mockError = new Error('Test error');
(mockError as any).customProp = { nested: 'value' };
// Call logError directly to verify it handles complex errors
logger.logError('[Test] Test message', mockError);
expect(logErrorSpy).toHaveBeenCalledWith('[Test] Test message', mockError);
// Verify the actual logger implementation doesn't produce [object Object]
const consoleErrorSpy = vi.spyOn(console, 'error');
vi.restoreAllMocks();
// Call real logError
logger.logError('[Test] Real test', mockError);
const output = consoleErrorSpy.mock.calls
.map(call => call.join(' '))
.join(' ');
const output = consoleErrorSpy.mock.calls.map((call) => call.join(' ')).join(' ');
// Should not contain [object Object]
expect(output).not.toContain('[object Object]');
});

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
/**
* Integration tests for thumbnail URL validation in the complete extraction flow
*
*
* These tests verify that URL validation works correctly in realistic scenarios:
* - Complete extraction flow with failing URLs falls back to screenshot
* - Valid URLs are successfully fetched and used
@@ -184,21 +184,21 @@ describe('Thumbnail URL Validation Integration', () => {
/**
* Example of how integration tests could be structured with real mocking:
*
*
* import { chromium } from 'playwright';
* import { extractTextAndThumbnail } from '$lib/server/extraction';
*
*
* it('should validate URL and fall back', async () => {
* const browser = await chromium.launch();
* const context = await browser.newContext();
* const page = await context.newPage();
*
*
* // Mock the page content
* await page.setContent(`
* <meta property="og:image" content="https://example.com/invalid.jpg">
* <video poster="https://example.com/also-invalid.jpg"></video>
* `);
*
*
* // Mock fetch to return 404 for these URLs
* await page.route('**\/*', route => {
* if (route.request().url().includes('invalid.jpg')) {
@@ -207,23 +207,23 @@ describe('Thumbnail URL Validation Integration', () => {
* route.continue();
* }
* });
*
*
* const progressEvents = [];
* const result = await extractTextAndThumbnail(
* 'https://instagram.com/p/test',
* (event) => progressEvents.push(event)
* );
*
*
* // Verify screenshot fallback was used
* expect(result.thumbnail).toMatch(/^data:image\/jpeg;base64,/);
*
*
* // Verify progress events show URL validation failures
* expect(progressEvents).toContainEqual(
* expect.objectContaining({
* message: expect.stringContaining('HTTP 404')
* })
* );
*
*
* await browser.close();
* });
*/

View File

@@ -8,19 +8,19 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
test('favicon.ico should exist', () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
expect(fs.existsSync(icoPath)).toBe(true);
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
expect(fs.existsSync(icoPath)).toBe(true);
});
test('favicon.ico should be 32x32', async () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.width).toBe(32);
expect(metadata.height).toBe(32);
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.width).toBe(32);
expect(metadata.height).toBe(32);
});
test('favicon.ico should be valid PNG format', async () => {
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.format).toBe('png');
const icoPath = path.join(__dirname, '..', '..', 'static', 'favicon.ico');
const metadata = await sharp(icoPath).metadata();
expect(metadata.format).toBe('png');
});

View File

@@ -8,30 +8,30 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('PWA Icon Generation - favicon.png', () => {
const faviconPath = path.join(__dirname, '..', '..', 'static', 'favicon.png');
const faviconPath = path.join(__dirname, '..', '..', 'static', 'favicon.png');
test('favicon.png should exist', () => {
expect(fs.existsSync(faviconPath)).toBe(true);
});
test('favicon.png should exist', () => {
expect(fs.existsSync(faviconPath)).toBe(true);
});
test('favicon.png should have exact 192x192 dimensions', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.width).toBe(192);
expect(metadata.height).toBe(192);
});
test('favicon.png should have exact 192x192 dimensions', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.width).toBe(192);
expect(metadata.height).toBe(192);
});
test('favicon.png should be PNG format', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.format).toBe('png');
});
test('favicon.png should be PNG format', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.format).toBe('png');
});
test('favicon.png should be less than 100KB', () => {
const stats = fs.statSync(faviconPath);
expect(stats.size).toBeLessThan(100 * 1024);
});
test('favicon.png should be less than 100KB', () => {
const stats = fs.statSync(faviconPath);
expect(stats.size).toBeLessThan(100 * 1024);
});
test('favicon.png should have RGBA channels', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.channels).toBe(4); // RGBA
});
test('favicon.png should have RGBA channels', async () => {
const metadata = await sharp(faviconPath).metadata();
expect(metadata.channels).toBe(4); // RGBA
});
});

View File

@@ -1,164 +1,164 @@
import fs from 'fs';
import path from 'path';
/**
* Test utilities for scheduler testing
*/
export const testFixtures = {
/**
* Create a mock auth.json file with valid Instagram session
*/
createMockAuthFile: (filePath: string) => {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const mockAuth = {
cookies: [
{
name: 'sessionid',
value: 'mock-session-' + Date.now(),
domain: '.instagram.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600 * 24 * 30,
httpOnly: true,
secure: true,
sameSite: 'Strict'
},
{
name: 'ig_did',
value: 'mock-did-' + Date.now(),
domain: '.instagram.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600 * 24 * 365,
httpOnly: false,
secure: true,
sameSite: 'Strict'
}
],
origins: [
{
origin: 'https://www.instagram.com',
localStorage: [
{
name: 'ig_nrcb',
value: '1'
}
]
}
]
};
fs.writeFileSync(filePath, JSON.stringify(mockAuth, null, 2));
return mockAuth;
},
/**
* Clean up mock auth files
*/
cleanupMockAuthFile: (filePath: string) => {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
const dir = path.dirname(filePath);
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
fs.rmdirSync(dir);
}
},
/**
* Mock environment for scheduler testing
*/
setupEnv: (config: Record<string, string | undefined>) => {
const original: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(config)) {
original[key] = process.env[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return () => {
// Restore original env
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
},
/**
* Validate auth.json file structure
*/
validateAuthFile: (filePath: string): boolean => {
try {
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Check required fields
if (!Array.isArray(content.cookies)) return false;
if (!Array.isArray(content.origins)) return false;
// Check cookie structure
for (const cookie of content.cookies) {
if (!cookie.name || !cookie.value || !cookie.domain) {
return false;
}
}
return true;
} catch {
return false;
}
},
/**
* Get mock browser context for testing
*/
createMockBrowserContext: () => {
return {
newPage: async () => ({
goto: async () => {},
waitForSelector: async () => {},
evaluate: async () => 'Home',
close: async () => {},
screenshot: async () => Buffer.from('mock-image')
}),
storageState: async (options: { path: string }) => {
const mockAuth = {
cookies: [{ name: 'sessionid', value: 'refreshed', domain: '.instagram.com' }],
origins: []
};
fs.writeFileSync(options.path, JSON.stringify(mockAuth, null, 2));
},
close: async () => {}
};
}
};
/**
* Helper to create a spy for interval/timeout functions
*/
export const createTimerSpy = () => {
let timers: NodeJS.Timeout[] = [];
return {
setInterval: (callback: () => void, ms: number) => {
const timer = setInterval(callback, ms);
timers.push(timer);
return timer;
},
cleanup: () => {
timers.forEach((timer) => clearInterval(timer));
timers = [];
}
};
};
import fs from 'fs';
import path from 'path';
/**
* Test utilities for scheduler testing
*/
export const testFixtures = {
/**
* Create a mock auth.json file with valid Instagram session
*/
createMockAuthFile: (filePath: string) => {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const mockAuth = {
cookies: [
{
name: 'sessionid',
value: 'mock-session-' + Date.now(),
domain: '.instagram.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600 * 24 * 30,
httpOnly: true,
secure: true,
sameSite: 'Strict'
},
{
name: 'ig_did',
value: 'mock-did-' + Date.now(),
domain: '.instagram.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600 * 24 * 365,
httpOnly: false,
secure: true,
sameSite: 'Strict'
}
],
origins: [
{
origin: 'https://www.instagram.com',
localStorage: [
{
name: 'ig_nrcb',
value: '1'
}
]
}
]
};
fs.writeFileSync(filePath, JSON.stringify(mockAuth, null, 2));
return mockAuth;
},
/**
* Clean up mock auth files
*/
cleanupMockAuthFile: (filePath: string) => {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
const dir = path.dirname(filePath);
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
fs.rmdirSync(dir);
}
},
/**
* Mock environment for scheduler testing
*/
setupEnv: (config: Record<string, string | undefined>) => {
const original: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(config)) {
original[key] = process.env[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return () => {
// Restore original env
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
},
/**
* Validate auth.json file structure
*/
validateAuthFile: (filePath: string): boolean => {
try {
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Check required fields
if (!Array.isArray(content.cookies)) return false;
if (!Array.isArray(content.origins)) return false;
// Check cookie structure
for (const cookie of content.cookies) {
if (!cookie.name || !cookie.value || !cookie.domain) {
return false;
}
}
return true;
} catch {
return false;
}
},
/**
* Get mock browser context for testing
*/
createMockBrowserContext: () => {
return {
newPage: async () => ({
goto: async () => {},
waitForSelector: async () => {},
evaluate: async () => 'Home',
close: async () => {},
screenshot: async () => Buffer.from('mock-image')
}),
storageState: async (options: { path: string }) => {
const mockAuth = {
cookies: [{ name: 'sessionid', value: 'refreshed', domain: '.instagram.com' }],
origins: []
};
fs.writeFileSync(options.path, JSON.stringify(mockAuth, null, 2));
},
close: async () => {}
};
}
};
/**
* Helper to create a spy for interval/timeout functions
*/
export const createTimerSpy = () => {
let timers: NodeJS.Timeout[] = [];
return {
setInterval: (callback: () => void, ms: number) => {
const timer = setInterval(callback, ms);
timers.push(timer);
return timer;
},
cleanup: () => {
timers.forEach((timer) => clearInterval(timer));
timers = [];
}
};
};

View File

@@ -4,45 +4,45 @@ import fs from 'fs';
import path from 'path';
describe('Icon 512x512 Generation', () => {
const iconPath = path.resolve('static/icon-512.png');
const iconPath = path.resolve('static/icon-512.png');
it('should exist', () => {
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should exist', () => {
expect(fs.existsSync(iconPath)).toBe(true);
});
it('should have correct dimensions (512x512)', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.width).toBe(512);
expect(metadata.height).toBe(512);
});
it('should have correct dimensions (512x512)', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.width).toBe(512);
expect(metadata.height).toBe(512);
});
it('should be PNG format', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.format).toBe('png');
});
it('should be PNG format', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.format).toBe('png');
});
it('should have valid RGBA encoding', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.channels).toBeGreaterThanOrEqual(3); // At least RGB
});
it('should have valid RGBA encoding', async () => {
const metadata = await sharp(iconPath).metadata();
expect(metadata.channels).toBeGreaterThanOrEqual(3); // At least RGB
});
it('should be less than 200KB', () => {
const stats = fs.statSync(iconPath);
const sizeInKB = stats.size / 1024;
// Note: With current icon-source.png (672KB RGB), achieving both <200KB AND RGBA
// is not possible with lossless PNG compression. Trade-off: prioritize file size for web performance
expect(sizeInKB).toBeLessThan(300); // Relaxed from 200KB due to source image constraints
});
it('should be less than 200KB', () => {
const stats = fs.statSync(iconPath);
const sizeInKB = stats.size / 1024;
// Note: With current icon-source.png (672KB RGB), achieving both <200KB AND RGBA
// is not possible with lossless PNG compression. Trade-off: prioritize file size for web performance
expect(sizeInKB).toBeLessThan(300); // Relaxed from 200KB due to source image constraints
});
it('should have transparency support (alpha channel)', async () => {
const metadata = await sharp(iconPath).metadata();
// Note: Source image is RGB without alpha. When using palette optimization for file size,
// Sharp removes unused alpha channel. This is acceptable as transparency is not needed for this icon.
expect(metadata.channels).toBeGreaterThanOrEqual(3); // Accept RGB or RGBA
});
it('should have transparency support (alpha channel)', async () => {
const metadata = await sharp(iconPath).metadata();
// Note: Source image is RGB without alpha. When using palette optimization for file size,
// Sharp removes unused alpha channel. This is acceptable as transparency is not needed for this icon.
expect(metadata.channels).toBeGreaterThanOrEqual(3); // Accept RGB or RGBA
});
it('should not be corrupted', async () => {
// Try to read the image - will throw if corrupted
await expect(sharp(iconPath).metadata()).resolves.toBeDefined();
});
it('should not be corrupted', async () => {
// Try to read the image - will throw if corrupted
await expect(sharp(iconPath).metadata()).resolves.toBeDefined();
});
});

View File

@@ -1,18 +1,18 @@
/**
* E2E Test for Instagram Caption Extraction
*
*
* JIRA: RECIPE-0006
*
*
* CURRENT STATUS: Instagram actively prevents web scraping.
* - All extraction methods (JSON, DOM, Internal State) return only truncated text (≤130 chars)
* - Full captions are loaded dynamically via GraphQL after user interaction
* - "More" button expansion requires complex interaction simulation
*
*
* This test validates that:
* 1. Multiple extraction strategies are attempted
* 2. The test fails if ALL strategies produce truncated output
* 3. Anti-scraping detection is working
*
*
* To get full captions, consider:
* - Official Instagram Graph API (requires authentication)
* - Manual user flow simulation with authenticated browser
@@ -29,19 +29,20 @@ describe('Instagram Caption Extraction E2E', () => {
const browser = await getBrowser();
const context = await createBrowserContext('./secrets/auth.json');
const page = await context.newPage();
try {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
console.log('[DEBUG] Navigating to:', testUrl);
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Search for links in different ways
const shortcode = 'DP6oN7JCEo8';
console.log(`\n[DEBUG] Searching for links with shortcode: ${shortcode}`);
// Method 1: Contains shortcode anywhere
const links1 = await page.locator(`a[href*="${shortcode}"]`).all();
console.log(`Method 1 - a[href*="${shortcode}"]: Found ${links1.length} links`);
@@ -49,11 +50,11 @@ describe('Instagram Caption Extraction E2E', () => {
const href = await links1[i].getAttribute('href');
console.log(` [${i}] ${href}`);
}
// Method 2: Get ALL links and filter
const allLinks = await page.locator('a').all();
console.log(`\n[DEBUG] Total links on page: ${allLinks.length}`);
let matchingLinks = 0;
for (const link of allLinks) {
const href = await link.getAttribute('href');
@@ -64,14 +65,13 @@ describe('Instagram Caption Extraction E2E', () => {
}
}
console.log(`Found ${matchingLinks} links containing shortcode`);
//Method 3: Check page HTML directly
const html = await page.content();
const htmlMatches = (html.match(new RegExp(shortcode, 'g')) || []).length;
console.log(`\n[DEBUG] Shortcode appears ${htmlMatches} times in page HTML`);
expect(true).toBe(true);
} finally {
await page.close();
await context.close();
@@ -82,29 +82,33 @@ describe('Instagram Caption Extraction E2E', () => {
const browser = await getBrowser();
const context = await createBrowserContext('./secrets/auth.json');
const page = await context.newPage();
try {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
console.log('[DEBUG] Navigating to:', testUrl);
await page.goto(testUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000); // Let page settle
// Take BEFORE screenshot
await page.screenshot({ path: 'debug_before.png', fullPage: true });
console.log('[DEBUG] BEFORE screenshot saved');
// Try to find and click "more" button
console.log('[DEBUG] Looking for "more" button...');
const moreElements = await page.locator('span, div, button').filter({ hasText: /more/i }).all();
const moreElements = await page
.locator('span, div, button')
.filter({ hasText: /more/i })
.all();
console.log(`[DEBUG] Found ${moreElements.length} elements with "more"`);
for (let i = 0; i < Math.min(moreElements.length, 10); i++) {
const el = moreElements[i];
const text = await el.textContent();
const visible = await el.isVisible().catch(() => false);
console.log(` [${i}] "${text}" visible:${visible}`);
if (visible && text && text.toLowerCase().includes('more')) {
console.log(` -> Attempting to click element ${i}`);
try {
@@ -117,16 +121,16 @@ describe('Instagram Caption Extraction E2E', () => {
}
}
}
// Take AFTER screenshot
await page.screenshot({ path: 'debug_after.png', fullPage: true });
console.log('[DEBUG] AFTER screenshot saved');
// Analyze spans again
const spanData = await page.evaluate(() => {
const spans = Array.from(document.querySelectorAll('span'));
return spans
.filter(s => (s.textContent || '').length > 30)
.filter((s) => (s.textContent || '').length > 30)
.map((s, idx) => ({
index: idx,
text: (s.textContent || '').substring(0, 200),
@@ -137,15 +141,16 @@ describe('Instagram Caption Extraction E2E', () => {
}))
.sort((a, b) => b.length - a.length); // Sort by text length
});
console.log('[DEBUG] Top spans by LENGTH after click attempt:');
spanData.slice(0, 5).forEach(span => {
console.log(` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`);
spanData.slice(0, 5).forEach((span) => {
console.log(
` [${span.index}] BR:${span.brCount} Links:${span.linkCount} Len:${span.length}`
);
console.log(` Text: "${span.text}"`);
});
expect(true).toBe(true); // Dummy assertion
} finally {
await page.close();
await context.close();
@@ -155,27 +160,28 @@ describe('Instagram Caption Extraction E2E', () => {
it('should extract complete recipe without metadata prefix (or at least try all methods)', async () => {
// Instagram's current anti-scraping measures make full extraction difficult
// This test validates that we try all available methods
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const result = await extractTextAndThumbnail(testUrl);
// Verify extraction succeeded
expect(result).toBeDefined();
expect(result.bodyText).toBeDefined();
console.log('[Test] Extracted text length:', result.bodyText.length);
console.log('[Test] Full text:', result.bodyText);
// Verify no HTML tags remain in the extracted text
expect(result.bodyText).not.toMatch(/<[^>]+>/);
expect(result.bodyText).not.toMatch(/&nbsp;/);
expect(result.bodyText).not.toMatch(/&amp;/);
// Verify line breaks are preserved (should have multiple lines)
const lines = result.bodyText.split('\n');
expect(lines.length).toBeGreaterThan(5); // Recipe should have multiple lines
// If we got more than 130 chars, great! If not, that's OK too (Instagram blocks us)
if (result.bodyText.length > 130) {
// We succeeded! Validate quality
@@ -191,21 +197,22 @@ describe('Instagram Caption Extraction E2E', () => {
}, 30000);
it('should handle extraction attempt and return truncated text gracefully', async () => {
const testUrl = 'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const testUrl =
'https://www.instagram.com/reel/DP6oN7JCEo8/?utm_source=ig_web_button_share_sheet';
const result = await extractTextAndThumbnail(testUrl);
// Verify extraction returns something
expect(result).toBeDefined();
expect(result.bodyText).toBeDefined();
expect(result.bodyText.length).toBeGreaterThan(0);
// Should start with recipe title (even if truncated)
expect(result.bodyText).toMatch(/^La cacio e pepe/i);
// Should have thumbnail
expect(result.thumbnail).toBeDefined();
console.log(`[Test] Extracted ${result.bodyText.length} chars (Instagram limits scraping)`);
}, 30000);
});

View File

@@ -1,11 +1,11 @@
/**
* Unit tests for Instagram caption extraction and cleaning
* JIRA: RECIPE-0006
*
*
* Tests the cleanText() and extractFromDOM() functions with mocked Playwright Page fixtures.
* Uses exact problematic output from real Instagram data to validate metadata prefix removal,
* quote handling, and hashtag cleaning.
*
*
* This replaces slow E2E tests (30s, flaky) with fast unit tests (<100ms, deterministic).
*/
@@ -17,7 +17,7 @@ describe('cleanText()', () => {
it('should remove hashtags from end of text', () => {
const input = 'Recipe instructions here #cacio #pepe #recipe';
const result = cleanText(input);
expect(result).toBe('Recipe instructions here');
expect(result).not.toContain('#cacio');
expect(result).not.toContain('#pepe');
@@ -26,7 +26,7 @@ describe('cleanText()', () => {
it('should preserve hashtags in middle of text', () => {
const input = 'Try this #amazing recipe for pasta';
const result = cleanText(input);
expect(result).toContain('#amazing');
expect(result).toBe('Try this #amazing recipe for pasta');
});
@@ -37,7 +37,7 @@ Liked by user123 and others
View all 50 comments
Add a comment...`;
const result = cleanText(input);
expect(result).toBe('Recipe text');
expect(result).not.toContain('Liked by');
expect(result).not.toContain('View all');
@@ -47,14 +47,14 @@ Add a comment...`;
it('should normalize excessive whitespace', () => {
const input = 'Recipe with extra spaces';
const result = cleanText(input);
expect(result).toBe('Recipe with extra spaces');
});
it('should handle international characters in hashtags', () => {
const input = 'Ricetta italiana #cacio #pepé #àncora';
const result = cleanText(input);
expect(result).toBe('Ricetta italiana');
});
});
@@ -64,12 +64,12 @@ describe('extractFromDOM() with mocked og:description', () => {
// Simulates what the browser's page.evaluate() would return after cleaning metadata
const createMockPage = (ogContent: string | null) => {
// Simulate the browser's metadata cleaning logic
const cleanedContent = ogContent
const cleanedContent = ogContent
? ogContent.replace(/^\d+K?\s+likes,\s+\d+\s+comments\s+-\s+[\w.]+\s+on\s+[^:]+:\s*["']?/, '')
: null;
let evaluateCallCount = 0;
return {
evaluate: vi.fn().mockImplementation(async () => {
evaluateCallCount++;
@@ -91,12 +91,13 @@ describe('extractFromDOM() with mocked og:description', () => {
it('should remove metadata prefix from og:description fallback', async () => {
// Exact fixture from context_compact.yaml
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const ogContent =
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const mockPage = createMockPage(ogContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).not.toContain('16K likes');
expect(result?.bodyText).not.toContain('chef.antonio.la.cava');
@@ -104,12 +105,13 @@ describe('extractFromDOM() with mocked og:description', () => {
});
it('should remove opening quote after metadata prefix', async () => {
const ogContent = '16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const ogContent =
'16K likes, 325 comments - chef.antonio.la.cava on October 17, 2025: "La cacio e pepe infallibile di Luciano Monosilio 🍝';
const mockPage = createMockPage(ogContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).not.toMatch(/^"/);
expect(result?.bodyText).toMatch(/^La cacio e pepe/);
@@ -117,31 +119,31 @@ describe('extractFromDOM() with mocked og:description', () => {
it('should handle metadata prefix with various like counts (K suffix)', async () => {
const ogContent = '1K likes, 50 comments - user.name on January 1, 2025: "Recipe text here';
const mockPage = createMockPage(ogContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).toBe('Recipe text here');
});
it('should handle metadata prefix without K suffix', async () => {
const ogContent = '500 likes, 20 comments - username on May 5, 2024: Recipe content';
const mockPage = createMockPage(ogContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).toBe('Recipe content');
});
it('should return null when no content available', async () => {
const mockPage = createMockPage(null);
const result = await extractFromDOM(mockPage);
expect(result).toBeNull();
});
});
@@ -168,41 +170,43 @@ describe('Integration: Full extraction flow', () => {
it('should extract, clean metadata prefix, remove quotes, and clean hashtags', async () => {
// Simulating what the browser's page.evaluate() would return AFTER cleaning metadata
// (the browser regex already strips the metadata prefix and quotes)
const browserCleanedContent = 'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
const browserCleanedContent =
'La cacio e pepe infallibile di Luciano Monosilio 🍝 #cacio #pepe #recipe';
const mockPage = createMockPage(browserCleanedContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
// Verify no metadata prefix
expect(result?.bodyText).not.toContain('16K likes');
expect(result?.bodyText).not.toContain('chef.antonio.la.cava');
// Verify no opening quote
expect(result?.bodyText).not.toMatch(/^"/);
// Verify starts with actual content
expect(result?.bodyText).toMatch(/^La cacio e pepe/);
// Verify hashtags removed from end
expect(result?.bodyText).not.toContain('#cacio');
expect(result?.bodyText).not.toContain('#pepe');
expect(result?.bodyText).not.toContain('#recipe');
// Verify clean output
expect(result?.bodyText).toBe('La cacio e pepe infallibile di Luciano Monosilio 🍝');
});
it('should handle full real-world caption with multiline content', async () => {
// Browser has already cleaned metadata, only hashtags remain
const browserCleanedContent = 'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
const browserCleanedContent =
'La cacio e pepe\n\nIngredients:\n- Pasta\n- Cheese\n\n#recipe #pasta';
const mockPage = createMockPage(browserCleanedContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).toMatch(/^La cacio e pepe/);
expect(result?.bodyText).toContain('Ingredients:');
@@ -213,11 +217,11 @@ describe('Integration: Full extraction flow', () => {
it('should preserve emojis in extracted text', async () => {
const browserCleanedContent = 'Recipe 🍝 with emojis 🙏🏻 📝';
const mockPage = createMockPage(browserCleanedContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).toContain('🍝');
expect(result?.bodyText).toContain('🙏🏻');
@@ -226,22 +230,22 @@ describe('Integration: Full extraction flow', () => {
it('should handle content without hashtags', async () => {
const browserCleanedContent = 'Simple recipe text';
const mockPage = createMockPage(browserCleanedContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).toBe('Simple recipe text');
});
it('should handle single quote instead of double quote', async () => {
const browserCleanedContent = 'Recipe with single quote';
const mockPage = createMockPage(browserCleanedContent);
const result = await extractFromDOM(mockPage);
expect(result).not.toBeNull();
expect(result?.bodyText).not.toMatch(/^'/);
expect(result?.bodyText).toBe('Recipe with single quote');

View File

@@ -76,9 +76,6 @@ describe('llm.ts logging', () => {
await checkModelAvailability('test-model');
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Model availability check failed',
complexError
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Model availability check failed', complexError);
});
});

View File

@@ -2,157 +2,154 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { serializeError, serializeObject, logError, logObject } from '$lib/server/utils/logger';
describe('logger utilities', () => {
describe('serializeError', () => {
test('handles Error objects', () => {
const error = new Error('Test error message');
const result = serializeError(error);
expect(result).toContain('Test error message');
expect(result).toContain('"name": "Error"');
expect(result).toContain('"message"');
});
test('handles plain objects', () => {
const obj = { code: 404, message: 'Not found' };
const result = serializeError(obj);
expect(result).toContain('"code": 404');
expect(result).toContain('"message": "Not found"');
});
test('includes stack trace for Error objects', () => {
const error = new Error('Stack test');
const result = serializeError(error);
expect(result).toContain('"stack"');
});
test('handles Error with custom properties', () => {
const error = new Error('Custom error') as any;
error.statusCode = 500;
error.details = { info: 'extra data' };
const result = serializeError(error);
expect(result).toContain('"statusCode": 500');
expect(result).toContain('extra data');
});
});
describe('serializeObject', () => {
test('handles circular references', () => {
const obj: any = { a: 1, b: 2 };
obj.self = obj;
const result = serializeObject(obj);
expect(result).toContain('[Circular]');
expect(result).toContain('"a": 1');
});
test('handles deeply nested objects', () => {
const obj = {
level1: {
level2: {
level3: {
value: 'deep'
}
}
}
};
const result = serializeObject(obj);
expect(result).toContain('"value": "deep"');
});
test('handles arrays', () => {
const obj = { items: [1, 2, 3] };
const result = serializeObject(obj);
expect(result).toContain('"items"');
expect(result).toContain('[');
});
test('handles null and undefined', () => {
const obj = { a: null, b: undefined };
const result = serializeObject(obj);
expect(result).toContain('"a": null');
});
});
describe('logError', () => {
let consoleErrorSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
test('outputs to console.error', () => {
const error = new Error('Test');
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith('[Test]', 'Test');
});
test('logs stack trace for Error objects', () => {
const error = new Error('Stack error');
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/Stack/),
expect.any(String)
);
});
test('handles non-Error objects', () => {
const obj = { code: 500, message: 'Server error' };
logError('[Test]', obj);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"code": 500')
);
});
});
describe('logObject', () => {
let consoleLogSpy: any;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
test('outputs to console.log', () => {
const obj = { key: 'value' };
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"key": "value"')
);
});
test('handles circular references', () => {
const obj: any = { a: 1 };
obj.self = obj;
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('[Circular]')
);
});
});
describe('serializeError', () => {
test('handles Error objects', () => {
const error = new Error('Test error message');
const result = serializeError(error);
expect(result).toContain('Test error message');
expect(result).toContain('"name": "Error"');
expect(result).toContain('"message"');
});
test('handles plain objects', () => {
const obj = { code: 404, message: 'Not found' };
const result = serializeError(obj);
expect(result).toContain('"code": 404');
expect(result).toContain('"message": "Not found"');
});
test('includes stack trace for Error objects', () => {
const error = new Error('Stack test');
const result = serializeError(error);
expect(result).toContain('"stack"');
});
test('handles Error with custom properties', () => {
const error = new Error('Custom error') as any;
error.statusCode = 500;
error.details = { info: 'extra data' };
const result = serializeError(error);
expect(result).toContain('"statusCode": 500');
expect(result).toContain('extra data');
});
});
describe('serializeObject', () => {
test('handles circular references', () => {
const obj: any = { a: 1, b: 2 };
obj.self = obj;
const result = serializeObject(obj);
expect(result).toContain('[Circular]');
expect(result).toContain('"a": 1');
});
test('handles deeply nested objects', () => {
const obj = {
level1: {
level2: {
level3: {
value: 'deep'
}
}
}
};
const result = serializeObject(obj);
expect(result).toContain('"value": "deep"');
});
test('handles arrays', () => {
const obj = { items: [1, 2, 3] };
const result = serializeObject(obj);
expect(result).toContain('"items"');
expect(result).toContain('[');
});
test('handles null and undefined', () => {
const obj = { a: null, b: undefined };
const result = serializeObject(obj);
expect(result).toContain('"a": null');
});
});
describe('logError', () => {
let consoleErrorSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
test('outputs to console.error', () => {
const error = new Error('Test');
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith('[Test]', 'Test');
});
test('logs stack trace for Error objects', () => {
const error = new Error('Stack error');
logError('[Test]', error);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/Stack/),
expect.any(String)
);
});
test('handles non-Error objects', () => {
const obj = { code: 500, message: 'Server error' };
logError('[Test]', obj);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"code": 500')
);
});
});
describe('logObject', () => {
let consoleLogSpy: any;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
test('outputs to console.log', () => {
const obj = { key: 'value' };
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Test]',
expect.stringContaining('"key": "value"')
);
});
test('handles circular references', () => {
const obj: any = { a: 1 };
obj.self = obj;
logObject('[Test]', obj);
expect(consoleLogSpy).toHaveBeenCalledWith('[Test]', expect.stringContaining('[Circular]'));
});
});
});

View File

@@ -1,6 +1,6 @@
/**
* Tests for Test Notification API Endpoint
*
*
* Verifies /api/notifications/test endpoint functionality including:
* - Type validation
* - Payload structure
@@ -12,179 +12,181 @@ import { POST } from '../routes/api/notifications/test/+server';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
describe('POST /api/notifications/test', () => {
let sendNotificationSpy: any;
let getSubscriptionCountSpy: any;
let sendNotificationSpy: any;
let getSubscriptionCountSpy: any;
beforeEach(() => {
vi.clearAllMocks();
// Spy on pushNotificationService methods
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
getSubscriptionCountSpy = vi.spyOn(pushNotificationService, 'getSubscriptionCount').mockReturnValue(2);
});
beforeEach(() => {
vi.clearAllMocks();
test('should validate notification type - reject invalid type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'invalid' })
});
// Spy on pushNotificationService methods
sendNotificationSpy = vi.spyOn(pushNotificationService, 'sendNotification').mockResolvedValue();
getSubscriptionCountSpy = vi
.spyOn(pushNotificationService, 'getSubscriptionCount')
.mockReturnValue(2);
});
const response = await POST({ request } as any);
const data = await response.json();
test('should validate notification type - reject invalid type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'invalid' })
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
const response = await POST({ request } as any);
const data = await response.json();
test('should validate notification type - reject missing type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
const response = await POST({ request } as any);
const data = await response.json();
test('should validate notification type - reject missing type', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
const response = await POST({ request } as any);
const data = await response.json();
test('should send test success notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
expect(response.status).toBe(400);
expect(data.error).toContain('Invalid notification type');
expect(sendNotificationSpy).not.toHaveBeenCalled();
});
const response = await POST({ request } as any);
const data = await response.json();
test('should send test success notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('success');
expect(data.subscriberCount).toBe(2);
const response = await POST({ request } as any);
const data = await response.json();
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
body: expect.stringContaining('Test recipe'),
recipeName: 'Test Recipe',
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-success-test_\d+$/),
requireInteraction: false
})
);
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('success');
expect(data.subscriberCount).toBe(2);
test('should send test error notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'error' })
});
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
body: expect.stringContaining('Test recipe'),
recipeName: 'Test Recipe',
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-success-test_\d+$/),
requireInteraction: false
})
);
});
const response = await POST({ request } as any);
const data = await response.json();
test('should send test error notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'error' })
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('error');
const response = await POST({ request } as any);
const data = await response.json();
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
body: expect.stringContaining('test error'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-error-test_\d+$/),
requireInteraction: true
})
);
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('error');
test('should send test progress notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'progress' })
});
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
body: expect.stringContaining('test error'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-error-test_\d+$/),
requireInteraction: true
})
);
});
const response = await POST({ request } as any);
const data = await response.json();
test('should send test progress notification', async () => {
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'progress' })
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('progress');
const response = await POST({ request } as any);
const data = await response.json();
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'progress',
body: expect.stringContaining('parsing phase'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-progress-test_\d+$/),
requireInteraction: false
})
);
});
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.message).toContain('progress');
test('should return subscriber count in response', async () => {
getSubscriptionCountSpy.mockReturnValue(5);
expect(sendNotificationSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'progress',
body: expect.stringContaining('parsing phase'),
itemId: expect.stringMatching(/^test_\d+$/),
tag: expect.stringMatching(/^recipe-progress-test_\d+$/),
requireInteraction: false
})
);
});
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
test('should return subscriber count in response', async () => {
getSubscriptionCountSpy.mockReturnValue(5);
const response = await POST({ request } as any);
const data = await response.json();
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
expect(data.subscriberCount).toBe(5);
expect(getSubscriptionCountSpy).toHaveBeenCalled();
});
const response = await POST({ request } as any);
const data = await response.json();
test('should handle sendNotification errors', async () => {
sendNotificationSpy.mockRejectedValue(new Error('Push service error'));
expect(data.subscriberCount).toBe(5);
expect(getSubscriptionCountSpy).toHaveBeenCalled();
});
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
test('should handle sendNotification errors', async () => {
sendNotificationSpy.mockRejectedValue(new Error('Push service error'));
const response = await POST({ request } as any);
const data = await response.json();
const request = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
expect(response.status).toBe(500);
expect(data.error).toContain('Failed to send test notification');
});
const response = await POST({ request } as any);
const data = await response.json();
test('should generate unique itemId for each request', async () => {
const request1 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
expect(response.status).toBe(500);
expect(data.error).toContain('Failed to send test notification');
});
const request2 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
test('should generate unique itemId for each request', async () => {
const request1 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
await POST({ request: request1 } as any);
const call1 = sendNotificationSpy.mock.calls[0][0];
const request2 = new Request('http://localhost/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
// Wait a bit to ensure different timestamp
await new Promise(resolve => setTimeout(resolve, 2));
await POST({ request: request1 } as any);
const call1 = sendNotificationSpy.mock.calls[0][0];
await POST({ request: request2 } as any);
const call2 = sendNotificationSpy.mock.calls[1][0];
// Wait a bit to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 2));
expect(call1.itemId).not.toBe(call2.itemId);
expect(call1.tag).not.toBe(call2.tag);
});
await POST({ request: request2 } as any);
const call2 = sendNotificationSpy.mock.calls[1][0];
expect(call1.itemId).not.toBe(call2.itemId);
expect(call1.tag).not.toBe(call2.tag);
});
});

View File

@@ -47,10 +47,7 @@ describe('parser.ts logging', () => {
// Expected to throw
}
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Recipe detection error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe detection error', expect.any(Error));
});
test('parseRecipe should use logError on failure', async () => {
@@ -60,10 +57,7 @@ describe('parser.ts logging', () => {
// Expected to throw
}
expect(logErrorSpy).toHaveBeenCalledWith(
'[LLM] Recipe parsing error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[LLM] Recipe parsing error', expect.any(Error));
});
test('should not log stack trace separately', async () => {
@@ -73,8 +67,9 @@ describe('parser.ts logging', () => {
// Expected to throw
}
const stackCalls = consoleErrorSpy.mock.calls
.filter((call: any) => call[0]?.includes('Stack trace'));
const stackCalls = consoleErrorSpy.mock.calls.filter((call: any) =>
call[0]?.includes('Stack trace')
);
expect(stackCalls).toHaveLength(0);
});

View File

@@ -4,190 +4,189 @@ import webpush from 'web-push';
// Mock web-push module BEFORE importing the service
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn()
}
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn()
}
}));
// Import service AFTER mocking
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
describe('PushNotificationService web-push integration', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear all subscriptions before each test
pushNotificationService.clearAllSubscriptions();
});
beforeEach(() => {
vi.clearAllMocks();
// Clear all subscriptions before each test
pushNotificationService.clearAllSubscriptions();
});
test('should have VAPID public key configured', () => {
// Verify the service has a public VAPID key available
const publicKey = pushNotificationService.getPublicVapidKey();
expect(publicKey).toBeTruthy();
expect(typeof publicKey).toBe('string');
expect(publicKey!.length).toBeGreaterThan(0);
});
test('should have VAPID public key configured', () => {
// Verify the service has a public VAPID key available
const publicKey = pushNotificationService.getPublicVapidKey();
expect(publicKey).toBeTruthy();
expect(typeof publicKey).toBe('string');
expect(publicKey!.length).toBeGreaterThan(0);
});
test('should send notification with web-push', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
test('should send notification with web-push', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-1', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-123',
body: 'Test notification'
});
await pushNotificationService.subscribe('client-1', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-123',
body: 'Test notification'
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: mockSubscription.endpoint,
keys: mockSubscription.keys
}),
expect.any(String),
expect.objectContaining({
TTL: 60 * 60 * 24
})
);
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: mockSubscription.endpoint,
keys: mockSubscription.keys
}),
expect.any(String),
expect.objectContaining({
TTL: 60 * 60 * 24
})
);
});
test('should handle subscription expiration (410)', async () => {
const mockError: any = new Error('Gone');
mockError.statusCode = 410;
test('should handle subscription expiration (410)', async () => {
const mockError: any = new Error('Gone');
mockError.statusCode = 410;
vi.mocked(webpush.sendNotification).mockRejectedValue(mockError);
vi.mocked(webpush.sendNotification).mockRejectedValue(mockError);
const mockSubscription = {
endpoint: 'https://push.example.com/expired',
keys: { p256dh: 'test', auth: 'test' }
};
const mockSubscription = {
endpoint: 'https://push.example.com/expired',
keys: { p256dh: 'test', auth: 'test' }
};
await pushNotificationService.subscribe('client-1', mockSubscription);
// Verify subscription exists before sending
expect(pushNotificationService.getSubscriptionCount()).toBe(1);
await pushNotificationService.subscribe('client-1', mockSubscription);
// sendNotification catches errors internally and removes invalid subscriptions
// It doesn't throw, so we just await it
await pushNotificationService.sendNotification({
type: 'error',
itemId: 'test',
body: 'Test'
});
// Verify subscription exists before sending
expect(pushNotificationService.getSubscriptionCount()).toBe(1);
// Verify the subscription was removed due to 410 error
expect(pushNotificationService.getSubscriptionCount()).toBe(0);
});
// sendNotification catches errors internally and removes invalid subscriptions
// It doesn't throw, so we just await it
await pushNotificationService.sendNotification({
type: 'error',
itemId: 'test',
body: 'Test'
});
test('should send notification with TTL of 24 hours', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-ttl',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
// Verify the subscription was removed due to 410 error
expect(pushNotificationService.getSubscriptionCount()).toBe(0);
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
test('should send notification with TTL of 24 hours', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-ttl',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
await pushNotificationService.subscribe('client-2', mockSubscription);
await pushNotificationService.sendNotification({
type: 'progress',
itemId: 'test-456',
body: 'Progress update'
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
expect(webpush.sendNotification).toHaveBeenCalledWith(
expect.any(Object),
expect.any(String),
{ TTL: 60 * 60 * 24 }
);
});
await pushNotificationService.subscribe('client-2', mockSubscription);
await pushNotificationService.sendNotification({
type: 'progress',
itemId: 'test-456',
body: 'Progress update'
});
test('should serialize notification data as JSON', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-json',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
expect(webpush.sendNotification).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
TTL: 60 * 60 * 24
});
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
test('should serialize notification data as JSON', async () => {
const mockSubscription = {
endpoint: 'https://push.example.com/test-json',
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
const testPayload = {
type: 'success' as const,
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
};
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-3', mockSubscription);
await pushNotificationService.sendNotification(testPayload);
const testPayload = {
type: 'success' as const,
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
};
const sendCallArgs = vi.mocked(webpush.sendNotification).mock.calls[0];
const sentPayload = sendCallArgs[1];
// Verify the payload is stringified JSON
expect(typeof sentPayload).toBe('string');
const parsedPayload = JSON.parse(sentPayload);
expect(parsedPayload).toMatchObject({
type: 'success',
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
});
});
await pushNotificationService.subscribe('client-3', mockSubscription);
await pushNotificationService.sendNotification(testPayload);
test('should handle multiple subscriptions', async () => {
const mockSubscription1 = {
endpoint: 'https://push.example.com/client1',
keys: { p256dh: 'key1', auth: 'auth1' }
};
const mockSubscription2 = {
endpoint: 'https://push.example.com/client2',
keys: { p256dh: 'key2', auth: 'auth2' }
};
const sendCallArgs = vi.mocked(webpush.sendNotification).mock.calls[0];
const sentPayload = sendCallArgs[1];
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
// Verify the payload is stringified JSON
expect(typeof sentPayload).toBe('string');
const parsedPayload = JSON.parse(sentPayload);
expect(parsedPayload).toMatchObject({
type: 'success',
itemId: 'test-789',
body: 'JSON test',
recipeName: 'Test Recipe'
});
});
await pushNotificationService.subscribe('client-1', mockSubscription1);
await pushNotificationService.subscribe('client-2', mockSubscription2);
test('should handle multiple subscriptions', async () => {
const mockSubscription1 = {
endpoint: 'https://push.example.com/client1',
keys: { p256dh: 'key1', auth: 'auth1' }
};
const mockSubscription2 = {
endpoint: 'https://push.example.com/client2',
keys: { p256dh: 'key2', auth: 'auth2' }
};
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-multi',
body: 'Multi-subscriber test'
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
// Should have sent to both subscribers
expect(webpush.sendNotification).toHaveBeenCalledTimes(2);
});
await pushNotificationService.subscribe('client-1', mockSubscription1);
await pushNotificationService.subscribe('client-2', mockSubscription2);
test('should log endpoint prefix only (privacy)', async () => {
const consoleSpy = vi.spyOn(console, 'log');
const longEndpoint = 'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
const mockSubscription = {
endpoint: longEndpoint,
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-multi',
body: 'Multi-subscriber test'
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
// Should have sent to both subscribers
expect(webpush.sendNotification).toHaveBeenCalledTimes(2);
});
await pushNotificationService.subscribe('client-privacy', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-privacy',
body: 'Privacy test'
});
test('should log endpoint prefix only (privacy)', async () => {
const consoleSpy = vi.spyOn(console, 'log');
// Find the log call with endpoint
const endpointLogCall = consoleSpy.mock.calls.find(
call => typeof call[0] === 'string' && call[0].includes('Sent notification to')
);
const longEndpoint =
'https://push.example.com/very-long-endpoint-with-secret-tokens-that-should-not-be-fully-logged-12345678901234567890';
const mockSubscription = {
endpoint: longEndpoint,
keys: { p256dh: 'test-key', auth: 'test-auth' }
};
expect(endpointLogCall).toBeTruthy();
// Should log only first 50 chars + ellipsis, not the full endpoint
expect(endpointLogCall![0]).toContain(longEndpoint.substring(0, 50));
expect(endpointLogCall![0]).not.toContain('secret-tokens');
});
vi.mocked(webpush.sendNotification).mockResolvedValue({} as any);
await pushNotificationService.subscribe('client-privacy', mockSubscription);
await pushNotificationService.sendNotification({
type: 'success',
itemId: 'test-privacy',
body: 'Privacy test'
});
// Find the log call with endpoint
const endpointLogCall = consoleSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && call[0].includes('Sent notification to')
);
expect(endpointLogCall).toBeTruthy();
// Should log only first 50 chars + ellipsis, not the full endpoint
expect(endpointLogCall![0]).toContain(longEndpoint.substring(0, 50));
expect(endpointLogCall![0]).not.toContain('secret-tokens');
});
});

View File

@@ -1,6 +1,6 @@
/**
* E2E Tests for Push Notifications
*
*
* Tests the complete push notification workflow using Playwright:
* - Permission granting
* - Subscription creation
@@ -8,197 +8,199 @@
* - Manual test notifications
* - Unsubscribe flow
* - localStorage persistence
*
*
* Note: These tests require the dev server to be running.
*/
import { test, expect, type BrowserContext } from '@playwright/test';
test.describe('Push Notifications E2E', () => {
let context: BrowserContext;
let context: BrowserContext;
test.beforeEach(async ({ browser }) => {
// Create new context with notification permissions granted
context = await browser.newContext();
await context.grantPermissions(['notifications']);
});
test.beforeEach(async ({ browser }) => {
// Create new context with notification permissions granted
context = await browser.newContext();
await context.grantPermissions(['notifications']);
});
test.afterEach(async () => {
await context?.close();
});
test.afterEach(async () => {
await context?.close();
});
test('should subscribe to push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
test('should subscribe to push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Wait for service worker to be registered
await page.waitForFunction(() => 'serviceWorker' in navigator && 'PushManager' in window);
// Wait for service worker to be registered
await page.waitForFunction(() => 'serviceWorker' in navigator && 'PushManager' in window);
// Find the notification toggle button
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await expect(toggleButton).toBeVisible();
// Click to enable notifications
await toggleButton.click();
// Wait for subscription to complete
await page.waitForTimeout(2000);
// Find the notification toggle button
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await expect(toggleButton).toBeVisible();
// Verify subscription was created in browser
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
return sub ? {
endpoint: sub.endpoint,
hasKeys: !!(sub as any).keys
} : null;
});
// Click to enable notifications
await toggleButton.click();
expect(subscription).not.toBeNull();
expect(subscription?.endpoint).toBeTruthy();
expect(subscription?.endpoint).toContain('https://');
expect(subscription?.hasKeys).toBe(true);
// Wait for subscription to complete
await page.waitForTimeout(2000);
// Verify button text changed to "Disable Notifications"
await expect(toggleButton).toHaveText(/disable notifications/i);
await page.close();
});
// Verify subscription was created in browser
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
return sub
? {
endpoint: sub.endpoint,
hasKeys: !!(sub as any).keys
}
: null;
});
test('should show test notification buttons when subscribed', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
expect(subscription).not.toBeNull();
expect(subscription?.endpoint).toBeTruthy();
expect(subscription?.endpoint).toContain('https://');
expect(subscription?.hasKeys).toBe(true);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Verify button text changed to "Disable Notifications"
await expect(toggleButton).toHaveText(/disable notifications/i);
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
await page.close();
});
// Verify test buttons are visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
const testErrorButton = page.getByRole('button', { name: /test error/i });
const testProgressButton = page.getByRole('button', { name: /test progress/i });
test('should show test notification buttons when subscribed', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(testSuccessButton).toBeVisible();
await expect(testErrorButton).toBeVisible();
await expect(testProgressButton).toBeVisible();
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
await page.close();
});
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
test('should send test notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify test buttons are visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
const testErrorButton = page.getByRole('button', { name: /test error/i });
const testProgressButton = page.getByRole('button', { name: /test progress/i });
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
await expect(testSuccessButton).toBeVisible();
await expect(testErrorButton).toBeVisible();
await expect(testProgressButton).toBeVisible();
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
await page.close();
});
// Mock the test notification API response
await page.route('/api/notifications/test', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, subscriberCount: 1 })
});
});
test('should send test notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Click test success button
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await testSuccessButton.click();
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for and verify success message
const successMessage = page.getByText(/✓ test success notification sent/i);
await expect(successMessage).toBeVisible({ timeout: 5000 });
// Enable notifications first
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify message contains subscriber count
await expect(successMessage).toContainText('1 subscriber');
// Mock the test notification API response
await page.route('/api/notifications/test', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, subscriberCount: 1 })
});
});
// Wait for auto-dismiss
await expect(successMessage).not.toBeVisible({ timeout: 4000 });
// Click test success button
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await testSuccessButton.click();
await page.close();
});
// Wait for and verify success message
const successMessage = page.getByText(/✓ test success notification sent/i);
await expect(successMessage).toBeVisible({ timeout: 5000 });
test('should unsubscribe from push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify message contains subscriber count
await expect(successMessage).toContainText('1 subscriber');
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Wait for auto-dismiss
await expect(successMessage).not.toBeVisible({ timeout: 4000 });
// First subscribe
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
await page.close();
});
// Verify subscribed
await expect(toggleButton).toHaveText(/disable notifications/i);
test('should unsubscribe from push notifications', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Now unsubscribe
await toggleButton.click();
await page.waitForTimeout(2000);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Verify subscription was removed
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
});
// First subscribe
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
expect(subscription).toBeNull();
// Verify subscribed
await expect(toggleButton).toHaveText(/disable notifications/i);
// Verify button text changed back
await expect(toggleButton).toHaveText(/enable notifications/i);
// Now unsubscribe
await toggleButton.click();
await page.waitForTimeout(2000);
// Verify test buttons are no longer visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await expect(testSuccessButton).not.toBeVisible();
// Verify subscription was removed
const subscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
});
await page.close();
});
expect(subscription).toBeNull();
test('should persist clientId in localStorage', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify button text changed back
await expect(toggleButton).toHaveText(/enable notifications/i);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Verify test buttons are no longer visible
const testSuccessButton = page.getByRole('button', { name: /test success/i });
await expect(testSuccessButton).not.toBeVisible();
// Enable notifications
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
await page.close();
});
// Verify clientId is stored in localStorage
const clientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
test('should persist clientId in localStorage', async () => {
const page = await context.newPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
expect(clientId).toBeTruthy();
expect(clientId).toMatch(/^client_[a-f0-9-]+$/);
// Wait for service worker
await page.waitForFunction(() => 'serviceWorker' in navigator);
// Reload page and verify clientId persists
await page.reload();
await page.waitForLoadState('networkidle');
// Enable notifications
const toggleButton = page.getByRole('button', { name: /enable notifications/i });
await toggleButton.click();
await page.waitForTimeout(2000);
const persistedClientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
// Verify clientId is stored in localStorage
const clientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
expect(persistedClientId).toBe(clientId);
expect(clientId).toBeTruthy();
expect(clientId).toMatch(/^client_[a-f0-9-]+$/);
await page.close();
});
// Reload page and verify clientId persists
await page.reload();
await page.waitForLoadState('networkidle');
const persistedClientId = await page.evaluate(() => {
return localStorage.getItem('push-client-id');
});
expect(persistedClientId).toBe(clientId);
await page.close();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
/**
* Tests for QueueManager logging serialization
*
*
* Verifies that QueueManager uses logError utility for error serialization
* instead of console.error which outputs [object Object].
*/
@@ -11,98 +11,89 @@ import * as logger from '$lib/server/utils/logger';
import type { QueueUpdateCallback } from '$lib/server/queue/types';
describe('QueueManager logging', () => {
let manager: QueueManager;
let logErrorSpy: any;
let manager: QueueManager;
let logErrorSpy: any;
beforeEach(() => {
manager = new QueueManager();
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
beforeEach(() => {
manager = new QueueManager();
logErrorSpy = vi.spyOn(logger, 'logError').mockImplementation(() => {});
});
test('should use logError when subscriber throws error', () => {
const failingCallback: QueueUpdateCallback = () => {
throw new Error('Subscriber failed');
};
test('should use logError when subscriber throws error', () => {
const failingCallback: QueueUpdateCallback = () => {
throw new Error('Subscriber failed');
};
manager.subscribe(failingCallback);
manager.subscribe(failingCallback);
// Enqueue an item (this will notify subscribers)
manager.enqueue('https://instagram.com/p/test123');
// Enqueue an item (this will notify subscribers)
manager.enqueue('https://instagram.com/p/test123');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.any(Error)
);
});
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', expect.any(Error));
});
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_SUBSCRIBER',
message: 'Callback failed',
details: { reason: 'Network timeout' }
};
test('should serialize complex error objects', () => {
const complexError = {
code: 'ERR_SUBSCRIBER',
message: 'Callback failed',
details: { reason: 'Network timeout' }
};
const failingCallback: QueueUpdateCallback = () => {
throw complexError;
};
const failingCallback: QueueUpdateCallback = () => {
throw complexError;
};
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/test456');
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/test456');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
complexError
);
});
expect(logErrorSpy).toHaveBeenCalledWith('[QueueManager] Subscriber error', complexError);
});
test('should not prevent other subscribers from being notified on error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failingCallback: QueueUpdateCallback = () => {
throw new Error('First subscriber fails');
};
const successCallback = vi.fn();
test('should not prevent other subscribers from being notified on error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failingCallback: QueueUpdateCallback = () => {
throw new Error('First subscriber fails');
};
const successCallback = vi.fn();
manager.subscribe(failingCallback);
manager.subscribe(successCallback);
manager.subscribe(failingCallback);
manager.subscribe(successCallback);
manager.enqueue('https://instagram.com/p/test789');
manager.enqueue('https://instagram.com/p/test789');
// Error should be logged via logError
expect(logErrorSpy).toHaveBeenCalled();
// Error should be logged via logError
expect(logErrorSpy).toHaveBeenCalled();
// Second subscriber should still be called
expect(successCallback).toHaveBeenCalled();
// Second subscriber should still be called
expect(successCallback).toHaveBeenCalled();
// Should not contain [object Object] in console output
const errorMessages = consoleErrorSpy.mock.calls
.map(call => call.join(' '));
// Should not contain [object Object] in console output
const errorMessages = consoleErrorSpy.mock.calls.map((call) => call.join(' '));
const hasObjectObject = errorMessages.some(msg =>
msg.includes('[object Object]')
);
const hasObjectObject = errorMessages.some((msg) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
});
expect(hasObjectObject).toBe(false);
});
test('should handle Error instances with custom properties', () => {
const customError: any = new Error('Custom error');
customError.statusCode = 500;
customError.details = { field: 'url', issue: 'invalid' };
test('should handle Error instances with custom properties', () => {
const customError: any = new Error('Custom error');
customError.statusCode = 500;
customError.details = { field: 'url', issue: 'invalid' };
const failingCallback: QueueUpdateCallback = () => {
throw customError;
};
const failingCallback: QueueUpdateCallback = () => {
throw customError;
};
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/custom');
manager.subscribe(failingCallback);
manager.enqueue('https://instagram.com/p/custom');
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.objectContaining({
message: 'Custom error',
statusCode: 500,
details: { field: 'url', issue: 'invalid' }
})
);
});
expect(logErrorSpy).toHaveBeenCalledWith(
'[QueueManager] Subscriber error',
expect.objectContaining({
message: 'Custom error',
statusCode: 500,
details: { field: 'url', issue: 'invalid' }
})
);
});
});

View File

@@ -1,6 +1,6 @@
/**
* Unit tests for QueueManager
*
*
* Tests core queue operations, status management, and pub/sub functionality.
*/
@@ -8,349 +8,349 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { QueueManager } from '$lib/server/queue/QueueManager';
describe('QueueManager', () => {
let queueManager: QueueManager;
beforeEach(() => {
// Create fresh instance for each test
queueManager = new QueueManager();
});
describe('enqueue', () => {
it('should enqueue items with unique IDs', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
expect(item1.id).toBeTruthy();
expect(item2.id).toBeTruthy();
expect(item1.id).not.toBe(item2.id);
});
it('should create items with pending status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
expect(item.enqueuedAt).toBeTruthy();
expect(item.logs).toEqual([]);
expect(item.progressEvents).toEqual([]);
expect(item.retryCount).toBe(0);
expect(item.maxRetries).toBe(3);
});
it('should notify subscribers when enqueueing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
status: 'pending'
})
);
});
});
describe('dequeue', () => {
it('should dequeue oldest pending item first (FIFO)', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
const dequeued1 = queueManager.dequeue();
expect(dequeued1?.id).toBe(item1.id);
const dequeued2 = queueManager.dequeue();
expect(dequeued2?.id).toBe(item2.id);
});
it('should return null when queue is empty', () => {
const item = queueManager.dequeue();
expect(item).toBeNull();
});
it('should mark dequeued item as in_progress', () => {
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
const dequeuedItem = queueManager.dequeue();
expect(dequeuedItem?.status).toBe('in_progress');
expect(dequeuedItem?.currentPhase).toBe('extraction');
expect(dequeuedItem?.startedAt).toBeTruthy();
});
it('should skip non-pending items', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
// Dequeue first item
queueManager.dequeue();
// Second item should be next
const dequeued = queueManager.dequeue();
expect(dequeued?.id).toBe(item2.id);
});
});
describe('updateStatus', () => {
it('should update item status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('in_progress');
expect(updated?.currentPhase).toBe('parsing');
});
it('should set completedAt for terminal statuses', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success');
const updated = queueManager.get(item.id);
expect(updated?.completedAt).toBeTruthy();
});
it('should merge additional data into item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success', {
recipe: { name: 'Test Recipe' },
tandoorRecipeId: 123
});
const updated = queueManager.get(item.id);
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
expect(updated?.tandoorRecipeId).toBe(123);
});
it('should handle error data', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const errorData = {
error: {
phase: 'extraction' as const,
message: 'Failed to load page',
recoverable: true,
timestamp: new Date().toISOString()
}
};
queueManager.updateStatus(item.id, 'unhealthy', errorData);
const updated = queueManager.get(item.id);
expect(updated?.error).toEqual(errorData.error);
});
});
describe('addProgressEvent', () => {
it('should add progress events to item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const event = {
type: 'status',
message: 'Extracting...',
timestamp: new Date().toISOString()
};
queueManager.addProgressEvent(item.id, event);
const updated = queueManager.get(item.id);
expect(updated?.progressEvents).toHaveLength(1);
expect(updated?.progressEvents[0]).toEqual(event);
});
it('should add event message to logs', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Test message',
timestamp: new Date().toISOString()
});
const updated = queueManager.get(item.id);
expect(updated?.logs).toContain('Test message');
});
it('should notify subscribers with event data', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear(); // Clear enqueue notification
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
queueManager.addProgressEvent(item.id, event);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { event }
})
);
});
});
describe('remove', () => {
it('should remove items by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const removed = queueManager.remove(item.id);
expect(removed).toBe(true);
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should return false for non-existent items', () => {
const removed = queueManager.remove('non-existent-id');
expect(removed).toBe(false);
});
it('should notify subscribers when removing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear();
queueManager.remove(item.id);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { removed: true }
})
);
});
});
describe('retry', () => {
it('should retry failed items', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
const retried = queueManager.retry(item.id);
expect(retried).toBe(true);
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('pending');
expect(updated?.retryCount).toBe(1);
expect(updated?.error).toBeUndefined();
expect(updated?.currentPhase).toBeUndefined();
});
it('should not retry items in progress', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress');
const retried = queueManager.retry(item.id);
expect(retried).toBe(false);
expect(queueManager.get(item.id)?.status).toBe('in_progress');
});
it('should increment retry count', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
queueManager.retry(item.id);
queueManager.retry(item.id);
expect(queueManager.get(item.id)?.retryCount).toBe(2);
});
});
describe('getAll', () => {
it('should return all queue items', () => {
queueManager.enqueue('https://instagram.com/p/test1');
queueManager.enqueue('https://instagram.com/p/test2');
queueManager.enqueue('https://instagram.com/p/test3');
const items = queueManager.getAll();
expect(items).toHaveLength(3);
});
it('should return empty array when queue is empty', () => {
const items = queueManager.getAll();
expect(items).toEqual([]);
});
});
describe('get', () => {
it('should return item by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const retrieved = queueManager.get(item.id);
expect(retrieved?.id).toBe(item.id);
expect(retrieved?.url).toBe(item.url);
});
it('should return undefined for non-existent ID', () => {
const item = queueManager.get('non-existent-id');
expect(item).toBeUndefined();
});
});
describe('subscribe', () => {
it('should notify subscribers of updates', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalled();
});
it('should return unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test1');
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
callback.mockClear();
queueManager.enqueue('https://instagram.com/p/test2');
expect(callback).not.toHaveBeenCalled();
});
it('should handle subscriber errors gracefully', () => {
const goodCallback = vi.fn();
const badCallback = vi.fn(() => {
throw new Error('Subscriber error');
});
queueManager.subscribe(goodCallback);
queueManager.subscribe(badCallback);
// Should not throw despite bad callback
expect(() => {
queueManager.enqueue('https://instagram.com/p/test');
}).not.toThrow();
// Good callback should still be called
expect(goodCallback).toHaveBeenCalled();
});
it('should support multiple subscribers', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
queueManager.subscribe(callback1);
queueManager.subscribe(callback2);
queueManager.subscribe(callback3);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
expect(callback3).toHaveBeenCalled();
});
});
let queueManager: QueueManager;
beforeEach(() => {
// Create fresh instance for each test
queueManager = new QueueManager();
});
describe('enqueue', () => {
it('should enqueue items with unique IDs', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
expect(item1.id).toBeTruthy();
expect(item2.id).toBeTruthy();
expect(item1.id).not.toBe(item2.id);
});
it('should create items with pending status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
expect(item.enqueuedAt).toBeTruthy();
expect(item.logs).toEqual([]);
expect(item.progressEvents).toEqual([]);
expect(item.retryCount).toBe(0);
expect(item.maxRetries).toBe(3);
});
it('should notify subscribers when enqueueing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
status: 'pending'
})
);
});
});
describe('dequeue', () => {
it('should dequeue oldest pending item first (FIFO)', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
const dequeued1 = queueManager.dequeue();
expect(dequeued1?.id).toBe(item1.id);
const dequeued2 = queueManager.dequeue();
expect(dequeued2?.id).toBe(item2.id);
});
it('should return null when queue is empty', () => {
const item = queueManager.dequeue();
expect(item).toBeNull();
});
it('should mark dequeued item as in_progress', () => {
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
const dequeuedItem = queueManager.dequeue();
expect(dequeuedItem?.status).toBe('in_progress');
expect(dequeuedItem?.currentPhase).toBe('extraction');
expect(dequeuedItem?.startedAt).toBeTruthy();
});
it('should skip non-pending items', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
// Dequeue first item
queueManager.dequeue();
// Second item should be next
const dequeued = queueManager.dequeue();
expect(dequeued?.id).toBe(item2.id);
});
});
describe('updateStatus', () => {
it('should update item status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('in_progress');
expect(updated?.currentPhase).toBe('parsing');
});
it('should set completedAt for terminal statuses', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success');
const updated = queueManager.get(item.id);
expect(updated?.completedAt).toBeTruthy();
});
it('should merge additional data into item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success', {
recipe: { name: 'Test Recipe' },
tandoorRecipeId: 123
});
const updated = queueManager.get(item.id);
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
expect(updated?.tandoorRecipeId).toBe(123);
});
it('should handle error data', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const errorData = {
error: {
phase: 'extraction' as const,
message: 'Failed to load page',
recoverable: true,
timestamp: new Date().toISOString()
}
};
queueManager.updateStatus(item.id, 'unhealthy', errorData);
const updated = queueManager.get(item.id);
expect(updated?.error).toEqual(errorData.error);
});
});
describe('addProgressEvent', () => {
it('should add progress events to item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const event = {
type: 'status',
message: 'Extracting...',
timestamp: new Date().toISOString()
};
queueManager.addProgressEvent(item.id, event);
const updated = queueManager.get(item.id);
expect(updated?.progressEvents).toHaveLength(1);
expect(updated?.progressEvents[0]).toEqual(event);
});
it('should add event message to logs', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Test message',
timestamp: new Date().toISOString()
});
const updated = queueManager.get(item.id);
expect(updated?.logs).toContain('Test message');
});
it('should notify subscribers with event data', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear(); // Clear enqueue notification
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
queueManager.addProgressEvent(item.id, event);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { event }
})
);
});
});
describe('remove', () => {
it('should remove items by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const removed = queueManager.remove(item.id);
expect(removed).toBe(true);
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should return false for non-existent items', () => {
const removed = queueManager.remove('non-existent-id');
expect(removed).toBe(false);
});
it('should notify subscribers when removing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear();
queueManager.remove(item.id);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { removed: true }
})
);
});
});
describe('retry', () => {
it('should retry failed items', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
const retried = queueManager.retry(item.id);
expect(retried).toBe(true);
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('pending');
expect(updated?.retryCount).toBe(1);
expect(updated?.error).toBeUndefined();
expect(updated?.currentPhase).toBeUndefined();
});
it('should not retry items in progress', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress');
const retried = queueManager.retry(item.id);
expect(retried).toBe(false);
expect(queueManager.get(item.id)?.status).toBe('in_progress');
});
it('should increment retry count', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
queueManager.retry(item.id);
queueManager.retry(item.id);
expect(queueManager.get(item.id)?.retryCount).toBe(2);
});
});
describe('getAll', () => {
it('should return all queue items', () => {
queueManager.enqueue('https://instagram.com/p/test1');
queueManager.enqueue('https://instagram.com/p/test2');
queueManager.enqueue('https://instagram.com/p/test3');
const items = queueManager.getAll();
expect(items).toHaveLength(3);
});
it('should return empty array when queue is empty', () => {
const items = queueManager.getAll();
expect(items).toEqual([]);
});
});
describe('get', () => {
it('should return item by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const retrieved = queueManager.get(item.id);
expect(retrieved?.id).toBe(item.id);
expect(retrieved?.url).toBe(item.url);
});
it('should return undefined for non-existent ID', () => {
const item = queueManager.get('non-existent-id');
expect(item).toBeUndefined();
});
});
describe('subscribe', () => {
it('should notify subscribers of updates', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalled();
});
it('should return unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test1');
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
callback.mockClear();
queueManager.enqueue('https://instagram.com/p/test2');
expect(callback).not.toHaveBeenCalled();
});
it('should handle subscriber errors gracefully', () => {
const goodCallback = vi.fn();
const badCallback = vi.fn(() => {
throw new Error('Subscriber error');
});
queueManager.subscribe(goodCallback);
queueManager.subscribe(badCallback);
// Should not throw despite bad callback
expect(() => {
queueManager.enqueue('https://instagram.com/p/test');
}).not.toThrow();
// Good callback should still be called
expect(goodCallback).toHaveBeenCalled();
});
it('should support multiple subscribers', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
queueManager.subscribe(callback1);
queueManager.subscribe(callback2);
queueManager.subscribe(callback3);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
expect(callback3).toHaveBeenCalled();
});
});
});

View File

@@ -2,19 +2,19 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock parser to avoid LLM calls
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
name: 'Test Recipe',
ingredients: [],
instructions: 'Test instructions',
servings: 4
}),
detectRecipe: vi.fn().mockResolvedValue(true)
extractRecipe: vi.fn().mockResolvedValue({
name: 'Test Recipe',
ingredients: [],
instructions: 'Test instructions',
servings: 4
}),
detectRecipe: vi.fn().mockResolvedValue(true)
}));
// Mock tandoor to avoid API calls
vi.mock('$lib/server/tandoor', () => ({
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({ id: 123 }),
uploadRecipeImage: vi.fn().mockResolvedValue(true)
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({ id: 123 }),
uploadRecipeImage: vi.fn().mockResolvedValue(true)
}));
import { queueManager } from '$lib/server/queue/QueueManager';
@@ -22,72 +22,74 @@ import * as extraction from '$lib/server/extraction';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
describe('QueueProcessor logging', () => {
let consoleErrorSpy: any;
beforeEach(async () => {
// Stop processor first
queueProcessor.stop();
// Clear queue
const items = queueManager.getAll();
items.forEach(item => queueManager.remove(item.id));
// Setup console.error spy
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Give time for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
});
afterEach(() => {
queueProcessor.stop();
consoleErrorSpy.mockRestore();
});
test('error logs should be properly serialized (no [object Object])', async () => {
// Create complex error object
const complexError = new Error('Test extraction error');
(complexError as any).code = 'ERR_TEST';
(complexError as any).details = { phase: 'extraction', retries: 3 };
// Mock extraction to fail BEFORE starting processor
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
extractSpy.mockRejectedValueOnce(complexError);
const item = queueManager.enqueue('https://instagram.com/p/TEST');
queueProcessor.start();
// Wait for error status
await vi.waitFor(() => {
const updated = queueManager.get(item.id);
return updated?.status === 'error' || updated?.status === 'unhealthy';
}, { timeout: 5000 });
// Stop processor
queueProcessor.stop();
// Wait a bit for all logs to finish
await new Promise(resolve => setTimeout(resolve, 100));
// Check that console.error doesn't contain [object Object]
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
call.map(arg => {
if (arg && typeof arg === 'object' && arg.message) {
return arg.message; // Handle Error objects
}
return String(arg);
}).join(' ')
);
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
// Verify QueueProcessor logs are present
const queueProcessorLogs = allCalls.filter((msg: string) =>
msg.includes('[QueueProcessor]')
);
expect(queueProcessorLogs.length).toBeGreaterThan(0);
});
let consoleErrorSpy: any;
beforeEach(async () => {
// Stop processor first
queueProcessor.stop();
// Clear queue
const items = queueManager.getAll();
items.forEach((item) => queueManager.remove(item.id));
// Setup console.error spy
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Give time for cleanup
await new Promise((resolve) => setTimeout(resolve, 100));
});
afterEach(() => {
queueProcessor.stop();
consoleErrorSpy.mockRestore();
});
test('error logs should be properly serialized (no [object Object])', async () => {
// Create complex error object
const complexError = new Error('Test extraction error');
(complexError as any).code = 'ERR_TEST';
(complexError as any).details = { phase: 'extraction', retries: 3 };
// Mock extraction to fail BEFORE starting processor
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
extractSpy.mockRejectedValueOnce(complexError);
const item = queueManager.enqueue('https://instagram.com/p/TEST');
queueProcessor.start();
// Wait for error status
await vi.waitFor(
() => {
const updated = queueManager.get(item.id);
return updated?.status === 'error' || updated?.status === 'unhealthy';
},
{ timeout: 5000 }
);
// Stop processor
queueProcessor.stop();
// Wait a bit for all logs to finish
await new Promise((resolve) => setTimeout(resolve, 100));
// Check that console.error doesn't contain [object Object]
const allCalls = consoleErrorSpy.mock.calls.map((call: any[]) =>
call
.map((arg) => {
if (arg && typeof arg === 'object' && arg.message) {
return arg.message; // Handle Error objects
}
return String(arg);
})
.join(' ')
);
const hasObjectObject = allCalls.some((msg: string) => msg.includes('[object Object]'));
expect(hasObjectObject).toBe(false);
// Verify QueueProcessor logs are present
const queueProcessorLogs = allCalls.filter((msg: string) => msg.includes('[QueueProcessor]'));
expect(queueProcessorLogs.length).toBeGreaterThan(0);
});
});

View File

@@ -1,6 +1,6 @@
/**
* Integration tests for QueueProcessor
*
*
* Tests the processor's ability to handle queue items through mocked dependencies.
* The QueueProcessor auto-starts, so these tests verify actual processing behavior.
*/
@@ -10,55 +10,56 @@ import { queueManager } from '$lib/server/queue/QueueManager';
// Mock web-push module BEFORE importing modules that depend on it
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined)
}
default: {
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined)
}
}));
// Mock queueConfig BEFORE importing QueueProcessor
vi.mock('$lib/server/queue/config', () => ({
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: {
enabled: true,
token: 'test-token',
serverUrl: 'http://localhost:8080'
},
push: {
vapidPublicKey: 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: 'mailto:test@example.com'
}
}
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: {
enabled: true,
token: 'test-token',
serverUrl: 'http://localhost:8080'
},
push: {
vapidPublicKey:
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
vapidPrivateKey: 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
vapidEmail: 'mailto:test@example.com'
}
}
}));
// Mock external dependencies BEFORE importing QueueProcessor
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
}));
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
})
extractRecipe: vi.fn().mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
})
}));
vi.mock('$lib/server/tandoor', () => ({
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
success: true,
recipeId: 999
}),
uploadRecipeImage: vi.fn().mockResolvedValue({
success: true
})
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
success: true,
recipeId: 999
}),
uploadRecipeImage: vi.fn().mockResolvedValue({
success: true
})
}));
import { extractTextAndThumbnail } from '$lib/server/extraction';
@@ -70,197 +71,195 @@ import * as configModule from '$lib/server/queue/config';
import '$lib/server/queue/QueueProcessor';
describe('QueueProcessor Integration Tests', () => {
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach(item => queueManager.remove(item.id));
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
servings: 2,
ingredients: [{ item: 'ingredient 1', amount: '1', unit: 'piece' }],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
afterEach(async () => {
// Wait for any pending processing to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
it('should process item through all phases when Tandoor is configured', async () => {
// Set up successful mocks
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe instructions here',
thumbnail: 'https://example.com/thumb.jpg'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Test Recipe',
servings: 4,
ingredients: [
{ item: 'flour', amount: '2', unit: 'cups' },
{ item: 'eggs', amount: '2', unit: 'pieces' }
],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 123
});
// Enqueue (processor is already running from auto-start)
// Note: Tandoor is enabled in the mocked config
const item = queueManager.enqueue('https://instagram.com/p/test-tandoor');
// Wait for processing to complete - increased timeout
await new Promise((resolve) => setTimeout(resolve, 1000));
const updated = queueManager.get(item.id);
// Verify success
expect(updated?.status).toBe('success');
expect(updated?.extractedText).toBe('Recipe instructions here');
expect(updated?.recipe?.name).toBe('Test Recipe');
expect(updated?.tandoorRecipeId).toBe(123);
// Verify all functions were called
expect(extractTextAndThumbnail).toHaveBeenCalled();
expect(extractRecipe).toHaveBeenCalled();
expect(uploadRecipeWithIngredientsDTO).toHaveBeenCalled();
}, 10000); // Increase timeout for processing
it('should skip Tandoor upload when not configured', async () => {
// Temporarily disable Tandoor for this test
const originalConfig = { ...configModule.queueConfig };
vi.spyOn(configModule, 'queueConfig', 'get').mockReturnValue({
...originalConfig,
tandoor: {
enabled: false,
token: null,
serverUrl: null
}
});
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should still succeed without Tandoor
expect(updated?.status).toBe('success');
expect(updated?.recipe?.name).toBe('No Tandoor Recipe');
expect(uploadRecipeWithIngredientsDTO).not.toHaveBeenCalled();
// Restore mock
vi.restoreAllMocks();
}, 10000);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
new Error('Network timeout')
);
const item = queueManager.enqueue('https://instagram.com/p/error');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as unhealthy (recoverable)
expect(updated?.status).toBe('unhealthy');
expect(updated?.error?.message).toContain('timeout');
}, 10000);
it('should handle parsing failure', async () => {
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Not a recipe',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue(null);
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as error (non-recoverable - no recipe found)
expect(updated?.status).toBe('error');
expect(updated?.error?.message).toContain('recipe');
}, 10000);
it('should process multiple items respecting concurrency', async () => {
// Set up mocks with delay to observe concurrency
vi.mocked(extractTextAndThumbnail).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return { bodyText: 'text', thumbnail: null };
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Concurrent Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
// Enqueue 3 items (Tandoor enabled by default in config mock)
queueManager.enqueue('https://instagram.com/p/item1');
queueManager.enqueue('https://instagram.com/p/item2');
queueManager.enqueue('https://instagram.com/p/item3');
// Wait a bit for processor to start working
await new Promise((resolve) => setTimeout(resolve, 150));
const items = queueManager.getAll();
const inProgress = items.filter(i => i.status === 'in_progress');
// With concurrency=2, should have max 2 in progress at once
expect(inProgress.length).toBeLessThanOrEqual(2);
// Wait for all to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
const final = queueManager.getAll();
const completed = final.filter(i => i.status === 'success');
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
servings: 2,
ingredients: [{ item: 'ingredient 1', amount: '1', unit: 'piece' }],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
afterEach(async () => {
// Wait for any pending processing to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
it('should process item through all phases when Tandoor is configured', async () => {
// Set up successful mocks
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe instructions here',
thumbnail: 'https://example.com/thumb.jpg'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Test Recipe',
servings: 4,
ingredients: [
{ item: 'flour', amount: '2', unit: 'cups' },
{ item: 'eggs', amount: '2', unit: 'pieces' }
],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 123
});
// Enqueue (processor is already running from auto-start)
// Note: Tandoor is enabled in the mocked config
const item = queueManager.enqueue('https://instagram.com/p/test-tandoor');
// Wait for processing to complete - increased timeout
await new Promise((resolve) => setTimeout(resolve, 1000));
const updated = queueManager.get(item.id);
// Verify success
expect(updated?.status).toBe('success');
expect(updated?.extractedText).toBe('Recipe instructions here');
expect(updated?.recipe?.name).toBe('Test Recipe');
expect(updated?.tandoorRecipeId).toBe(123);
// Verify all functions were called
expect(extractTextAndThumbnail).toHaveBeenCalled();
expect(extractRecipe).toHaveBeenCalled();
expect(uploadRecipeWithIngredientsDTO).toHaveBeenCalled();
}, 10000); // Increase timeout for processing
it('should skip Tandoor upload when not configured', async () => {
// Temporarily disable Tandoor for this test
const originalConfig = { ...configModule.queueConfig };
vi.spyOn(configModule, 'queueConfig', 'get').mockReturnValue({
...originalConfig,
tandoor: {
enabled: false,
token: null,
serverUrl: null
}
});
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should still succeed without Tandoor
expect(updated?.status).toBe('success');
expect(updated?.recipe?.name).toBe('No Tandoor Recipe');
expect(uploadRecipeWithIngredientsDTO).not.toHaveBeenCalled();
// Restore mock
vi.restoreAllMocks();
}, 10000);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(new Error('Network timeout'));
const item = queueManager.enqueue('https://instagram.com/p/error');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as unhealthy (recoverable)
expect(updated?.status).toBe('unhealthy');
expect(updated?.error?.message).toContain('timeout');
}, 10000);
it('should handle parsing failure', async () => {
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Not a recipe',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue(null);
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as error (non-recoverable - no recipe found)
expect(updated?.status).toBe('error');
expect(updated?.error?.message).toContain('recipe');
}, 10000);
it('should process multiple items respecting concurrency', async () => {
// Set up mocks with delay to observe concurrency
vi.mocked(extractTextAndThumbnail).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return { bodyText: 'text', thumbnail: null };
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Concurrent Recipe',
servings: null,
ingredients: [],
steps: [],
description: ''
});
// Enqueue 3 items (Tandoor enabled by default in config mock)
queueManager.enqueue('https://instagram.com/p/item1');
queueManager.enqueue('https://instagram.com/p/item2');
queueManager.enqueue('https://instagram.com/p/item3');
// Wait a bit for processor to start working
await new Promise((resolve) => setTimeout(resolve, 150));
const items = queueManager.getAll();
const inProgress = items.filter((i) => i.status === 'in_progress');
// With concurrency=2, should have max 2 in progress at once
expect(inProgress.length).toBeLessThanOrEqual(2);
// Wait for all to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
const final = queueManager.getAll();
const completed = final.filter((i) => i.status === 'success');
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
});

View File

@@ -1,6 +1,6 @@
/**
* Integration tests for Queue SSE Stream endpoint
*
*
* Tests the Server-Sent Events stream for real-time queue updates.
*/
@@ -9,133 +9,133 @@ import { queueManager } from '$lib/server/queue/QueueManager';
import { GET as streamGET } from '../routes/api/queue/stream/+server.js';
describe('Queue SSE Stream Endpoint', () => {
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
describe('GET /api/queue/stream', () => {
it('should return SSE response with correct headers', async () => {
const url = new URL('http://localhost/api/queue/stream');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
// Connection header no longer manually set - managed automatically by Node.js
});
it('should reject invalid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=invalid');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toContain('Invalid status filter');
});
it('should reject invalid item ID format', async () => {
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Invalid queue item ID format');
});
it('should accept valid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=pending');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should accept valid item ID filter', async () => {
// Add a test item first
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should handle stream initialization without errors', async () => {
// Add some test items
queueManager.enqueue('https://instagram.com/p/TEST1');
queueManager.enqueue('https://instagram.com/p/TEST2');
const url = new URL('http://localhost/api/queue/stream');
const abortController = new AbortController();
const request = new Request(url, {
signal: abortController.signal
});
const response = await streamGET({
url,
request
} as any);
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(ReadableStream);
// Abort the request to clean up
abortController.abort();
});
});
// Note: Full SSE stream testing would require more complex setup with
// ReadableStream readers and async iteration, which is beyond the scope
// of these basic endpoint validation tests. The above tests verify that:
// 1. The endpoint responds correctly
// 2. Headers are set properly for SSE
// 3. Parameter validation works
// 4. Stream initialization succeeds
});
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach((item) => queueManager.remove(item.id));
});
describe('GET /api/queue/stream', () => {
it('should return SSE response with correct headers', async () => {
const url = new URL('http://localhost/api/queue/stream');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
// Connection header no longer manually set - managed automatically by Node.js
});
it('should reject invalid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=invalid');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toContain('Invalid status filter');
});
it('should reject invalid item ID format', async () => {
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Invalid queue item ID format');
});
it('should accept valid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=pending');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should accept valid item ID filter', async () => {
// Add a test item first
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should handle stream initialization without errors', async () => {
// Add some test items
queueManager.enqueue('https://instagram.com/p/TEST1');
queueManager.enqueue('https://instagram.com/p/TEST2');
const url = new URL('http://localhost/api/queue/stream');
const abortController = new AbortController();
const request = new Request(url, {
signal: abortController.signal
});
const response = await streamGET({
url,
request
} as any);
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(ReadableStream);
// Abort the request to clean up
abortController.abort();
});
});
// Note: Full SSE stream testing would require more complex setup with
// ReadableStream readers and async iteration, which is beyond the scope
// of these basic endpoint validation tests. The above tests verify that:
// 1. The endpoint responds correctly
// 2. Headers are set properly for SSE
// 3. Parameter validation works
// 4. Stream initialization succeeds
});

View File

@@ -1,134 +1,134 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'path';
import fs from 'fs';
/**
* Integration tests for the scheduler
* These tests verify the scheduler behavior with mocked browser contexts
*/
describe('Scheduler Integration Tests', () => {
const mockAuthPath = path.join(__dirname, '../../__mocks__/auth.json');
const mockAuthDir = path.dirname(mockAuthPath);
beforeEach(() => {
// Create mock directory structure
if (!fs.existsSync(mockAuthDir)) {
fs.mkdirSync(mockAuthDir, { recursive: true });
}
// Create mock auth.json
const mockAuth = {
cookies: [
{
name: 'sessionid',
value: 'mock-session-id',
domain: '.instagram.com',
path: '/',
expires: Date.now() / 1000 + 3600 * 24 * 30, // 30 days
httpOnly: true,
secure: true,
sameSite: 'Strict'
}
],
origins: []
};
fs.writeFileSync(mockAuthPath, JSON.stringify(mockAuth, null, 2));
});
afterEach(() => {
// Cleanup mock files
if (fs.existsSync(mockAuthPath)) {
fs.unlinkSync(mockAuthPath);
}
if (fs.existsSync(mockAuthDir) && fs.readdirSync(mockAuthDir).length === 0) {
fs.rmdirSync(mockAuthDir);
}
});
describe('Auth File Management', () => {
it('should detect existing auth.json file', () => {
const exists = fs.existsSync(mockAuthPath);
expect(exists).toBe(true);
});
it('should preserve auth.json structure when renewed', () => {
const authContent = JSON.parse(fs.readFileSync(mockAuthPath, 'utf-8'));
expect(authContent).toHaveProperty('cookies');
expect(authContent).toHaveProperty('origins');
expect(Array.isArray(authContent.cookies)).toBe(true);
});
it('should create secrets directory if it does not exist', () => {
const secretsDir = path.join(__dirname, '../../__mocks__/secrets');
if (!fs.existsSync(secretsDir)) {
fs.mkdirSync(secretsDir, { recursive: true });
}
expect(fs.existsSync(secretsDir)).toBe(true);
// Cleanup
if (fs.readdirSync(secretsDir).length === 0) {
fs.rmdirSync(secretsDir);
}
});
});
describe('Scheduler Timing', () => {
it('should calculate correct interval from hours', () => {
const hours = 12;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(43200000);
});
it('should support 6-hour renewal interval', () => {
const hours = 6;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(21600000);
});
it('should support 24-hour renewal interval', () => {
const hours = 24;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(86400000);
});
});
describe('Error Handling', () => {
it('should handle missing auth.json gracefully', () => {
const nonExistentPath = path.join(__dirname, '../../__mocks__/nonexistent.json');
const exists = fs.existsSync(nonExistentPath);
expect(exists).toBe(false);
});
it('should validate auth.json structure', () => {
const authContent = JSON.parse(fs.readFileSync(mockAuthPath, 'utf-8'));
const hasRequiredFields = 'cookies' in authContent && 'origins' in authContent;
expect(hasRequiredFields).toBe(true);
});
});
describe('Path Resolution', () => {
it('should resolve Docker auth path when it exists', () => {
// This would be tested with actual file system mocks
const dockerPath = '/app/secrets/auth.json';
const localPath = './secrets/auth.json';
// In real scenario, mock fs.existsSync to return true for dockerPath
expect(dockerPath).toMatch(/\/app\/secrets\/auth\.json/);
});
it('should fall back to local path', () => {
const localPath = './secrets/auth.json';
expect(localPath).toMatch(/\.\/secrets\/auth\.json/);
});
});
});
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'path';
import fs from 'fs';
/**
* Integration tests for the scheduler
* These tests verify the scheduler behavior with mocked browser contexts
*/
describe('Scheduler Integration Tests', () => {
const mockAuthPath = path.join(__dirname, '../../__mocks__/auth.json');
const mockAuthDir = path.dirname(mockAuthPath);
beforeEach(() => {
// Create mock directory structure
if (!fs.existsSync(mockAuthDir)) {
fs.mkdirSync(mockAuthDir, { recursive: true });
}
// Create mock auth.json
const mockAuth = {
cookies: [
{
name: 'sessionid',
value: 'mock-session-id',
domain: '.instagram.com',
path: '/',
expires: Date.now() / 1000 + 3600 * 24 * 30, // 30 days
httpOnly: true,
secure: true,
sameSite: 'Strict'
}
],
origins: []
};
fs.writeFileSync(mockAuthPath, JSON.stringify(mockAuth, null, 2));
});
afterEach(() => {
// Cleanup mock files
if (fs.existsSync(mockAuthPath)) {
fs.unlinkSync(mockAuthPath);
}
if (fs.existsSync(mockAuthDir) && fs.readdirSync(mockAuthDir).length === 0) {
fs.rmdirSync(mockAuthDir);
}
});
describe('Auth File Management', () => {
it('should detect existing auth.json file', () => {
const exists = fs.existsSync(mockAuthPath);
expect(exists).toBe(true);
});
it('should preserve auth.json structure when renewed', () => {
const authContent = JSON.parse(fs.readFileSync(mockAuthPath, 'utf-8'));
expect(authContent).toHaveProperty('cookies');
expect(authContent).toHaveProperty('origins');
expect(Array.isArray(authContent.cookies)).toBe(true);
});
it('should create secrets directory if it does not exist', () => {
const secretsDir = path.join(__dirname, '../../__mocks__/secrets');
if (!fs.existsSync(secretsDir)) {
fs.mkdirSync(secretsDir, { recursive: true });
}
expect(fs.existsSync(secretsDir)).toBe(true);
// Cleanup
if (fs.readdirSync(secretsDir).length === 0) {
fs.rmdirSync(secretsDir);
}
});
});
describe('Scheduler Timing', () => {
it('should calculate correct interval from hours', () => {
const hours = 12;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(43200000);
});
it('should support 6-hour renewal interval', () => {
const hours = 6;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(21600000);
});
it('should support 24-hour renewal interval', () => {
const hours = 24;
const expectedMs = hours * 60 * 60 * 1000;
expect(expectedMs).toBe(86400000);
});
});
describe('Error Handling', () => {
it('should handle missing auth.json gracefully', () => {
const nonExistentPath = path.join(__dirname, '../../__mocks__/nonexistent.json');
const exists = fs.existsSync(nonExistentPath);
expect(exists).toBe(false);
});
it('should validate auth.json structure', () => {
const authContent = JSON.parse(fs.readFileSync(mockAuthPath, 'utf-8'));
const hasRequiredFields = 'cookies' in authContent && 'origins' in authContent;
expect(hasRequiredFields).toBe(true);
});
});
describe('Path Resolution', () => {
it('should resolve Docker auth path when it exists', () => {
// This would be tested with actual file system mocks
const dockerPath = '/app/secrets/auth.json';
const localPath = './secrets/auth.json';
// In real scenario, mock fs.existsSync to return true for dockerPath
expect(dockerPath).toMatch(/\/app\/secrets\/auth\.json/);
});
it('should fall back to local path', () => {
const localPath = './secrets/auth.json';
expect(localPath).toMatch(/\.\/secrets\/auth\.json/);
});
});
});

View File

@@ -1,205 +1,205 @@
import { getSchedulerStatus, startScheduler, stopScheduler } from '$lib/server/scheduler';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock environment variables
const { mockEnv } = vi.hoisted(() => {
return {
mockEnv: {
AUTH_SCHEDULER_ENABLED: 'false',
AUTH_SCHEDULER_INTERVAL_MINUTES: '720'
}
};
});
vi.mock('$env/dynamic/private', () => ({
env: mockEnv
}));
// Mock the browser module
vi.mock('$lib/server/browser', () => ({
getBrowser: vi.fn(),
initializeBrowser: vi.fn(),
closeBrowser: vi.fn()
}));
// Mock fs operations
const mockFs = {
existsSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn()
};
describe('Scheduler Service', () => {
beforeEach(() => {
// Reset environment variables
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '720';
// Clear all mocks
vi.clearAllMocks();
// Reset scheduler state by stopping if running
try {
stopScheduler();
} catch {
// Ignore if not running
}
});
afterEach(async () => {
// Ensure scheduler is stopped after each test
await stopScheduler();
});
describe('Configuration', () => {
it('should use default interval when AUTH_SCHEDULER_INTERVAL_MINUTES is not set', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '';
const status = getSchedulerStatus();
expect(status.config.intervalMinutes).toBe(720);
});
it('should parse custom interval minutes from environment', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '30';
const status = getSchedulerStatus();
expect(status.config.intervalMinutes).toBe(30);
});
it('should disable scheduler when AUTH_SCHEDULER_ENABLED is not true', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(false);
expect(status.running).toBe(false);
});
it('should parse AUTH_SCHEDULER_ENABLED as true when set to "true"', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(true);
});
});
describe('Scheduler Lifecycle', () => {
it('should not start when disabled', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(false);
});
it('should start when enabled', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(true);
});
it('should not start twice', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const consoleSpy = vi.spyOn(console, 'warn');
await startScheduler();
expect(consoleSpy).toHaveBeenCalledWith('[Scheduler] Scheduler is already running');
});
it('should stop the scheduler', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
expect(getSchedulerStatus().running).toBe(true);
await stopScheduler();
expect(getSchedulerStatus().running).toBe(false);
});
it('should handle stopping when not running', async () => {
const consoleSpy = vi.spyOn(console, 'log');
await stopScheduler();
expect(consoleSpy).toHaveBeenCalledWith('[Scheduler] Scheduler is not running');
});
});
describe('Status Reporting', () => {
it('should return scheduler status with default values', () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
const status = getSchedulerStatus();
expect(status).toEqual({
running: false,
lastRenewalTime: null,
isRenewing: false,
config: {
enabled: false,
intervalMinutes: 720
}
});
});
it('should report running state correctly', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(true);
expect(status.isRenewing).toBe(false);
});
it('should track configuration', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '1440';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(true);
expect(status.config.intervalMinutes).toBe(1440);
});
});
describe('Auth Renewal', () => {
it('should skip renewal if no auth.json exists', async () => {
mockFs.existsSync.mockReturnValue(false);
// Note: In a real test, you'd import and call the renewal function directly
// This test verifies the behavior when auth file is missing
expect(mockFs.existsSync.mock.calls.length).toBeGreaterThanOrEqual(0);
});
it('should prevent concurrent renewal attempts', async () => {
// This would be tested through integration tests with actual browser context
// The scheduler maintains state.isRenewing flag to prevent concurrent calls
const status = getSchedulerStatus();
expect(status.isRenewing).toBe(false);
});
});
describe('Environment Variables', () => {
it('should handle empty AUTH_SCHEDULER_INTERVAL_MINUTES with default', () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '';
const status = getSchedulerStatus();
// Empty string should fall back to default due to parseInt('', 10) returning NaN
// and the || 720 fallback
expect(status.config.intervalMinutes).toBeDefined();
});
});
});
import { getSchedulerStatus, startScheduler, stopScheduler } from '$lib/server/scheduler';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock environment variables
const { mockEnv } = vi.hoisted(() => {
return {
mockEnv: {
AUTH_SCHEDULER_ENABLED: 'false',
AUTH_SCHEDULER_INTERVAL_MINUTES: '720'
}
};
});
vi.mock('$env/dynamic/private', () => ({
env: mockEnv
}));
// Mock the browser module
vi.mock('$lib/server/browser', () => ({
getBrowser: vi.fn(),
initializeBrowser: vi.fn(),
closeBrowser: vi.fn()
}));
// Mock fs operations
const mockFs = {
existsSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn()
};
describe('Scheduler Service', () => {
beforeEach(() => {
// Reset environment variables
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '720';
// Clear all mocks
vi.clearAllMocks();
// Reset scheduler state by stopping if running
try {
stopScheduler();
} catch {
// Ignore if not running
}
});
afterEach(async () => {
// Ensure scheduler is stopped after each test
await stopScheduler();
});
describe('Configuration', () => {
it('should use default interval when AUTH_SCHEDULER_INTERVAL_MINUTES is not set', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '';
const status = getSchedulerStatus();
expect(status.config.intervalMinutes).toBe(720);
});
it('should parse custom interval minutes from environment', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '30';
const status = getSchedulerStatus();
expect(status.config.intervalMinutes).toBe(30);
});
it('should disable scheduler when AUTH_SCHEDULER_ENABLED is not true', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(false);
expect(status.running).toBe(false);
});
it('should parse AUTH_SCHEDULER_ENABLED as true when set to "true"', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(true);
});
});
describe('Scheduler Lifecycle', () => {
it('should not start when disabled', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(false);
});
it('should start when enabled', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(true);
});
it('should not start twice', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const consoleSpy = vi.spyOn(console, 'warn');
await startScheduler();
expect(consoleSpy).toHaveBeenCalledWith('[Scheduler] Scheduler is already running');
});
it('should stop the scheduler', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
expect(getSchedulerStatus().running).toBe(true);
await stopScheduler();
expect(getSchedulerStatus().running).toBe(false);
});
it('should handle stopping when not running', async () => {
const consoleSpy = vi.spyOn(console, 'log');
await stopScheduler();
expect(consoleSpy).toHaveBeenCalledWith('[Scheduler] Scheduler is not running');
});
});
describe('Status Reporting', () => {
it('should return scheduler status with default values', () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'false';
const status = getSchedulerStatus();
expect(status).toEqual({
running: false,
lastRenewalTime: null,
isRenewing: false,
config: {
enabled: false,
intervalMinutes: 720
}
});
});
it('should report running state correctly', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockFs.existsSync.mockReturnValue(true);
await startScheduler();
const status = getSchedulerStatus();
expect(status.running).toBe(true);
expect(status.isRenewing).toBe(false);
});
it('should track configuration', async () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '1440';
const status = getSchedulerStatus();
expect(status.config.enabled).toBe(true);
expect(status.config.intervalMinutes).toBe(1440);
});
});
describe('Auth Renewal', () => {
it('should skip renewal if no auth.json exists', async () => {
mockFs.existsSync.mockReturnValue(false);
// Note: In a real test, you'd import and call the renewal function directly
// This test verifies the behavior when auth file is missing
expect(mockFs.existsSync.mock.calls.length).toBeGreaterThanOrEqual(0);
});
it('should prevent concurrent renewal attempts', async () => {
// This would be tested through integration tests with actual browser context
// The scheduler maintains state.isRenewing flag to prevent concurrent calls
const status = getSchedulerStatus();
expect(status.isRenewing).toBe(false);
});
});
describe('Environment Variables', () => {
it('should handle empty AUTH_SCHEDULER_INTERVAL_MINUTES with default', () => {
mockEnv.AUTH_SCHEDULER_ENABLED = 'true';
mockEnv.AUTH_SCHEDULER_INTERVAL_MINUTES = '';
const status = getSchedulerStatus();
// Empty string should fall back to default due to parseInt('', 10) returning NaN
// and the || 720 fallback
expect(status.config.intervalMinutes).toBeDefined();
});
});
});

View File

@@ -1,6 +1,6 @@
/**
* Integration tests for SSE extraction endpoint
*
*
* Tests the real-time progress streaming from extraction to frontend
*/
@@ -11,31 +11,31 @@ describe('SSE Extraction Endpoint', () => {
it('should stream progress events for successful extraction', async () => {
// Mock Instagram URL (would need real URL for full e2e test)
const testUrl = 'https://www.instagram.com/p/test123/';
const events: ProgressEvent[] = [];
// Note: This is a structure test. Real testing requires:
// 1. Running server
// 2. Valid Instagram URL
// 3. Browser context available
// Expected event flow
const expectedEventTypes = [
'status', // Starting extraction
'status', // Loading page
'method', // Trying first method
'status', // Success or next method
'status', // Parsing recipe
'complete' // Final result
'status', // Starting extraction
'status', // Loading page
'method', // Trying first method
'status', // Success or next method
'status', // Parsing recipe
'complete' // Final result
];
expect(expectedEventTypes).toBeDefined();
});
it('should handle errors gracefully', async () => {
// Test with invalid URL
const invalidUrl = 'not-a-valid-url';
// Expected: error event should be sent
expect(invalidUrl).toBeTruthy();
});
@@ -92,14 +92,14 @@ describe('SSE Extraction Endpoint', () => {
describe('Frontend SSE Parser', () => {
it('should parse SSE event format correctly', () => {
const sseMessage = 'event: progress\ndata: {"type":"status","message":"test"}\n\n';
const eventMatch = sseMessage.match(/^event: (\w+)\ndata: (.+)$/s);
expect(eventMatch).toBeTruthy();
if (eventMatch) {
const [, eventType, eventData] = eventMatch;
expect(eventType).toBe('progress');
const parsed = JSON.parse(eventData.replace(/\n\n$/, ''));
expect(parsed.type).toBe('status');
expect(parsed.message).toBe('test');
@@ -112,7 +112,7 @@ describe('Frontend SSE Parser', () => {
'embedded-json': '📦',
'dom-selector': '🎯',
'graphql-api': '🔌',
'legacy': '📄'
legacy: '📄'
};
return method ? icons[method] || '⚙️' : '⚙️';
};
@@ -128,7 +128,7 @@ describe('Frontend SSE Parser', () => {
/**
* Manual E2E Testing Checklist:
*
*
* □ Start dev server: npm run dev
* □ Open /share?url=<instagram-url>
* □ Click "Extract Recipe"

View File

@@ -24,18 +24,13 @@ describe('tandoor logging', () => {
name: 'Test Recipe',
servings: 4,
description: 'Test description',
ingredients: [
{ item: 'Flour', amount: '2', unit: 'cups' }
],
ingredients: [{ item: 'Flour', amount: '2', unit: 'cups' }],
steps: ['Mix ingredients']
};
await uploadRecipeWithIngredientsDTO(recipe);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
});
test('should use logError on API error response', async () => {
@@ -80,10 +75,7 @@ describe('tandoor logging', () => {
await uploadRecipeWithIngredientsDTO(recipe);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
expect.any(Error)
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', expect.any(Error));
});
test('should use logError on image upload failure', async () => {
@@ -93,10 +85,7 @@ describe('tandoor logging', () => {
const result = await uploadRecipeImage(123, 'https://example.com/image.jpg');
expect(result.success).toBe(false);
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor Upload] Exception',
error
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor Upload] Exception', error);
});
test('should use logError instead of manual error logging', async () => {
@@ -112,11 +101,8 @@ describe('tandoor logging', () => {
});
// Verify logError was called (which handles stack trace serialization)
expect(logErrorSpy).toHaveBeenCalledWith(
'[Tandoor] Fetch error',
error
);
expect(logErrorSpy).toHaveBeenCalledWith('[Tandoor] Fetch error', error);
// logError itself logs stack traces, which is expected behavior
// The key is that tandoor.ts uses logError instead of manual logging
});

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* Unit tests for thumbnail URL validation in fetchImageAsBase64
*
*
* These tests verify that the enhanced URL validation:
* - Accepts only HTTP 200 status codes
* - Validates content-type is image/*

View File

@@ -6,7 +6,7 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
kit: {
adapter: adapter(),
serviceWorker: {
register: true // Enable SvelteKit's native service worker registration

View File

@@ -5,22 +5,22 @@ import { sveltekit } from '@sveltejs/kit/vite';
import fs from 'fs';
export default defineConfig({
define: {
'process.env.NODE_ENV': process.env.NODE_ENV === 'production' ? '"production"' : '"development"'
},
server: {
watch: {
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
},
https: fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
? {
key: fs.readFileSync('./.ssl/localhost.key'),
cert: fs.readFileSync('./.ssl/localhost.crt')
}
: undefined
},
plugins: [
tailwindcss(), sveltekit()],
define: {
'process.env.NODE_ENV': process.env.NODE_ENV === 'production' ? '"production"' : '"development"'
},
server: {
watch: {
ignored: ['**/debug_page.txt', '**/.ssl/**', '**/docs/**', '**/secrets/**']
},
https:
fs.existsSync('./.ssl/localhost.key') && fs.existsSync('./.ssl/localhost.crt')
? {
key: fs.readFileSync('./.ssl/localhost.key'),
cert: fs.readFileSync('./.ssl/localhost.crt')
}
: undefined
},
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [