simplify
This commit is contained in:
@@ -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",
|
||||
|
||||
40
README.md
40
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
486
docs/API.md
486
docs/API.md
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
611
docs/FINDINGS.md
611
docs/FINDINGS.md
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
158
docs/TESTING.md
158
docs/TESTING.md
@@ -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
@@ -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,
|
||||
|
||||
122
package.json
122
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }) => {
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
* });
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/ /);
|
||||
expect(result.bodyText).not.toMatch(/&/);
|
||||
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user