feat(RECIPE-0009): complete iteration 0 — deduplication, notifications, UI improvements
This commit is contained in:
120
src/tests/notification-auto-subscribe.svelte.spec.ts
Normal file
120
src/tests/notification-auto-subscribe.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user