feat(RECIPE-0009): complete iteration 0 — deduplication, notifications, UI improvements

This commit is contained in:
Giancarmine Salucci
2026-02-18 06:00:48 +01:00
parent 40e3fb0c1b
commit dfca35bde2
12 changed files with 864 additions and 395 deletions

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import HomePage from '../routes/+page.svelte';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true
}));
// Mock fetch for queue API calls
globalThis.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ items: [], stats: { total: 0, pending: 0, in_progress: 0, completed: 0, error: 0 } })
} as Response)
);
// Mock EventSource for SSE
globalThis.EventSource = vi.fn(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
onerror: null,
onmessage: null,
onopen: null,
readyState: 0,
url: '',
withCredentials: false,
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
dispatchEvent: vi.fn()
})) as any;
vi.mock('$lib/client/PushNotificationManager', () => ({
pushNotificationManager: {
getState: vi.fn(),
subscribe: vi.fn(),
onStateChange: vi.fn(() => () => {})
}
}));
describe('Automatic Notification Subscription', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
});
it('should auto-subscribe on first user click when supported and not subscribed', async () => {
render(HomePage);
// Wait for component to mount and set up event listeners
await new Promise(resolve => setTimeout(resolve, 100));
// Simulate user click anywhere on page
document.body.click();
// Wait for event handlers to process
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
});
it('should not auto-subscribe if permission denied', async () => {
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'denied',
subscribed: false,
loading: false,
error: null
});
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
});
it('should not auto-subscribe if already subscribed', async () => {
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
});
it('should only attempt subscription once', async () => {
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
document.body.click();
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -37,13 +37,14 @@ describe('Queue API Endpoints', () => {
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBeTruthy();
expect(data.url).toBe('https://instagram.com/p/ABC123');
expect(data.status).toBe('pending');
expect(data.enqueuedAt).toBeTruthy();
expect(data.duplicate).toBe(false);
expect(data.item.id).toBeTruthy();
expect(data.item.url).toBe('https://instagram.com/p/ABC123');
expect(data.item.status).toBe('pending');
expect(data.item.enqueuedAt).toBeTruthy();
// Verify item exists in queue
const item = queueManager.get(data.id);
// Verify item exists in queue
const item = queueManager.get(data.item.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://instagram.com/p/ABC123');
});
@@ -63,10 +64,11 @@ describe('Queue API Endpoints', () => {
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe('https://www.instagram.com/p/XYZ789');
// Verify item exists in queue
const item = queueManager.get(data.id);
// Verify item exists in queue
const item = queueManager.get(data.item.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
});
@@ -83,7 +85,8 @@ describe('Queue API Endpoints', () => {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe('https://instagram.com/reel/ABC123');
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe('https://instagram.com/reel/ABC123');
});
it('should accept Instagram URLs with query parameters', async () => {
@@ -98,7 +101,8 @@ describe('Queue API Endpoints', () => {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe(
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe(
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
);
});
@@ -234,6 +238,37 @@ describe('Queue API Endpoints', () => {
});
});
describe('POST /api/queue deduplication', () => {
it('should return duplicate flag when URL already exists', async () => {
const url = 'https://instagram.com/p/DUP123';
// First request
const request1 = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const response1 = await queuePOST({ request: request1 } as any);
const data1 = await response1.json();
expect(data1.duplicate).toBe(false);
// Second request (duplicate)
const request2 = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const response2 = await queuePOST({ request: request2 } as any);
const data2 = await response2.json();
expect(response2.status).toBe(200);
expect(data2.duplicate).toBe(true);
expect(data2.message).toContain('already in the queue');
expect(data2.item.id).toBe(data1.item.id);
});
});
describe('GET /api/queue', () => {
it('should return empty list when no items', async () => {
const url = new URL('http://localhost/api/queue');

View File

@@ -353,4 +353,28 @@ describe('QueueManager', () => {
expect(callback3).toHaveBeenCalled();
});
});
describe('deduplication', () => {
it('should return existing item when enqueueing duplicate URL', () => {
const url = 'https://instagram.com/p/ABC123';
const firstItem = queueManager.enqueue(url);
const secondItem = queueManager.enqueue(url);
expect(secondItem.id).toBe(firstItem.id);
expect(queueManager.getAll()).toHaveLength(1);
});
it('should find item by URL', () => {
const url = 'https://instagram.com/p/TEST123';
const item = queueManager.enqueue(url);
const found = queueManager.findByUrl(url);
expect(found?.id).toBe(item.id);
});
it('should return undefined when URL not found', () => {
const found = queueManager.findByUrl('https://instagram.com/p/NOTFOUND');
expect(found).toBeUndefined();
});
});
});