simplify
This commit is contained in:
@@ -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/*
|
||||
|
||||
Reference in New Issue
Block a user