499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
/**
|
|
* Offline Queue Service Tests
|
|
* Action 2.5.1.8: Test offline queue works correctly
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { offlineQueue, type QueuedRequest } from './offlineQueue';
|
|
import type { AxiosRequestConfig } from 'axios';
|
|
import { apiClient } from './api/client';
|
|
|
|
// Mock apiClient
|
|
vi.mock('./api/client', () => ({
|
|
apiClient: {
|
|
request: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {};
|
|
|
|
return {
|
|
getItem: (key: string) => store[key] || null,
|
|
setItem: (key: string, value: string) => {
|
|
store[key] = value.toString();
|
|
},
|
|
removeItem: (key: string) => {
|
|
delete store[key];
|
|
},
|
|
clear: () => {
|
|
store = {};
|
|
},
|
|
};
|
|
})();
|
|
|
|
Object.defineProperty(window, 'localStorage', {
|
|
value: localStorageMock,
|
|
});
|
|
|
|
// Mock navigator.onLine
|
|
Object.defineProperty(navigator, 'onLine', {
|
|
writable: true,
|
|
value: true,
|
|
});
|
|
|
|
describe('OfflineQueueService', () => {
|
|
beforeEach(() => {
|
|
// Clear queue before each test
|
|
offlineQueue.clearQueue();
|
|
localStorageMock.clear();
|
|
navigator.onLine = true;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
navigator.onLine = true;
|
|
});
|
|
|
|
describe('queueRequest', () => {
|
|
it('should queue POST requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
const requestId = await offlineQueue.queueRequest(config);
|
|
|
|
expect(requestId).toBeDefined();
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
|
|
const queue = offlineQueue.getQueue();
|
|
expect(queue.length).toBe(1);
|
|
expect(queue[0].config.method).toBe('POST');
|
|
expect(queue[0].config.url).toBe('/api/v1/tracks');
|
|
});
|
|
|
|
it('should not queue GET requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'GET',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
const shouldQueue = offlineQueue.shouldQueueRequest(config);
|
|
expect(shouldQueue).toBe(false);
|
|
});
|
|
|
|
it('should queue PUT requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'PUT',
|
|
url: '/api/v1/tracks/123',
|
|
data: { title: 'Updated Track' },
|
|
};
|
|
|
|
const requestId = await offlineQueue.queueRequest(config);
|
|
|
|
expect(requestId).toBeDefined();
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
});
|
|
|
|
it('should queue DELETE requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'DELETE',
|
|
url: '/api/v1/tracks/123',
|
|
};
|
|
|
|
const requestId = await offlineQueue.queueRequest(config);
|
|
|
|
expect(requestId).toBeDefined();
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
});
|
|
|
|
it('should queue PATCH requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'PATCH',
|
|
url: '/api/v1/tracks/123',
|
|
data: { title: 'Patched Track' },
|
|
};
|
|
|
|
const requestId = await offlineQueue.queueRequest(config);
|
|
|
|
expect(requestId).toBeDefined();
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
});
|
|
|
|
it('should respect priority levels', async () => {
|
|
const config1: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
const config2: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/playlists',
|
|
};
|
|
|
|
const config3: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/comments',
|
|
};
|
|
|
|
// Queue with different priorities
|
|
await offlineQueue.queueRequest(config1, { priority: 'low' });
|
|
await offlineQueue.queueRequest(config2, { priority: 'high' });
|
|
await offlineQueue.queueRequest(config3, { priority: 'normal' });
|
|
|
|
const queue = offlineQueue.getQueue();
|
|
|
|
// High priority should be first
|
|
expect(queue[0].priority).toBe('high');
|
|
expect(queue[0].config.url).toBe('/api/v1/playlists');
|
|
|
|
// Normal priority should be second
|
|
expect(queue[1].priority).toBe('normal');
|
|
expect(queue[1].config.url).toBe('/api/v1/comments');
|
|
|
|
// Low priority should be last
|
|
expect(queue[2].priority).toBe('low');
|
|
expect(queue[2].config.url).toBe('/api/v1/tracks');
|
|
});
|
|
|
|
it('should evict oldest low-priority request when queue is full', async () => {
|
|
// Fill queue to capacity (100 requests)
|
|
for (let i = 0; i < 100; i++) {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: `/api/v1/tracks/${i}`,
|
|
};
|
|
await offlineQueue.queueRequest(config, { priority: 'normal' });
|
|
}
|
|
|
|
expect(offlineQueue.getQueueSize()).toBe(100);
|
|
|
|
// Add one more request (should evict oldest low-priority)
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks/new',
|
|
};
|
|
await offlineQueue.queueRequest(config, { priority: 'low' });
|
|
|
|
// Queue size should still be 100
|
|
expect(offlineQueue.getQueueSize()).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('processQueue', () => {
|
|
it('should process queued requests when online', async () => {
|
|
const { apiClient } = await import('./api/client');
|
|
vi.mocked(apiClient.request).mockResolvedValue({
|
|
data: { success: true },
|
|
});
|
|
|
|
navigator.onLine = true;
|
|
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
|
|
// Process queue
|
|
await offlineQueue.processQueue();
|
|
|
|
// Request should have been sent
|
|
expect(apiClient.request).toHaveBeenCalledWith(config);
|
|
expect(offlineQueue.getQueueSize()).toBe(0);
|
|
});
|
|
|
|
it('should not process queue when offline', async () => {
|
|
const { apiClient } = await import('./api/client');
|
|
|
|
navigator.onLine = false;
|
|
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
|
|
// Process queue (should not process when offline)
|
|
await offlineQueue.processQueue();
|
|
|
|
// Request should not have been sent
|
|
expect(apiClient.request).not.toHaveBeenCalled();
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
});
|
|
|
|
it('should retry failed requests up to max retries', async () => {
|
|
const { apiClient } = await import('./api/client');
|
|
vi.mocked(apiClient.request).mockRejectedValue(
|
|
new Error('Network error'),
|
|
);
|
|
|
|
navigator.onLine = true;
|
|
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config, { maxRetries: 2 });
|
|
|
|
// Process queue (will fail and retry)
|
|
await offlineQueue.processQueue();
|
|
|
|
// Should have retried: initial attempt + 2 retries = 3 total attempts
|
|
// But maxRetries=2 means retryCount can reach 2, so total attempts = 3
|
|
// However, the implementation retries until retryCount >= maxRetries
|
|
// So with maxRetries=2: attempt 1 (retryCount=0), attempt 2 (retryCount=1), attempt 3 (retryCount=2, removed)
|
|
expect(apiClient.request).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should remove request from queue after max retries', async () => {
|
|
const { apiClient } = await import('./api/client');
|
|
vi.mocked(apiClient.request).mockRejectedValue(
|
|
new Error('Network error'),
|
|
);
|
|
|
|
navigator.onLine = true;
|
|
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config, { maxRetries: 1 });
|
|
|
|
// Process queue (will fail and exceed max retries)
|
|
await offlineQueue.processQueue();
|
|
|
|
// Request should be removed from queue after max retries
|
|
expect(offlineQueue.getQueueSize()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getQueueSize', () => {
|
|
it('should return current queue size', async () => {
|
|
expect(offlineQueue.getQueueSize()).toBe(0);
|
|
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
expect(offlineQueue.getQueueSize()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getQueue', () => {
|
|
it('should return copy of queue', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
|
|
const queue1 = offlineQueue.getQueue();
|
|
const queue2 = offlineQueue.getQueue();
|
|
|
|
// Should be different arrays (copies)
|
|
expect(queue1).not.toBe(queue2);
|
|
expect(queue1.length).toBe(queue2.length);
|
|
});
|
|
});
|
|
|
|
describe('clearQueue', () => {
|
|
it('should clear all queued requests', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
await offlineQueue.queueRequest(config);
|
|
|
|
expect(offlineQueue.getQueueSize()).toBe(2);
|
|
|
|
await offlineQueue.clearQueue();
|
|
|
|
expect(offlineQueue.getQueueSize()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('removeRequest', () => {
|
|
it('should remove specific request from queue', async () => {
|
|
const config1: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
const config2: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/playlists',
|
|
};
|
|
|
|
const id1 = await offlineQueue.queueRequest(config1);
|
|
const id2 = await offlineQueue.queueRequest(config2);
|
|
|
|
expect(offlineQueue.getQueueSize()).toBe(2);
|
|
|
|
const removed = await offlineQueue.removeRequest(id1);
|
|
|
|
expect(removed).toBe(true);
|
|
expect(offlineQueue.getQueueSize()).toBe(1);
|
|
|
|
const queue = offlineQueue.getQueue();
|
|
expect(queue[0].id).toBe(id2);
|
|
});
|
|
|
|
it('should return false if request not found', async () => {
|
|
const removed = await offlineQueue.removeRequest('nonexistent-id');
|
|
expect(removed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('shouldQueueRequest', () => {
|
|
it('should return false for GET requests', () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'GET',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
expect(offlineQueue.shouldQueueRequest(config)).toBe(false);
|
|
});
|
|
|
|
it('should return true for POST requests', () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
};
|
|
|
|
expect(offlineQueue.shouldQueueRequest(config)).toBe(true);
|
|
});
|
|
|
|
it('should return true for PUT requests', () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'PUT',
|
|
url: '/api/v1/tracks/123',
|
|
};
|
|
|
|
expect(offlineQueue.shouldQueueRequest(config)).toBe(true);
|
|
});
|
|
|
|
it('should return true for DELETE requests', () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'DELETE',
|
|
url: '/api/v1/tracks/123',
|
|
};
|
|
|
|
expect(offlineQueue.shouldQueueRequest(config)).toBe(true);
|
|
});
|
|
|
|
it('should return true for PATCH requests', () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'PATCH',
|
|
url: '/api/v1/tracks/123',
|
|
};
|
|
|
|
expect(offlineQueue.shouldQueueRequest(config)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('persistence', () => {
|
|
it('should persist queue to localStorage', async () => {
|
|
const config: AxiosRequestConfig = {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
data: { title: 'Test Track' },
|
|
};
|
|
|
|
await offlineQueue.queueRequest(config);
|
|
|
|
// Check localStorage
|
|
const stored = localStorageMock.getItem('veza_offline_queue');
|
|
expect(stored).not.toBeNull();
|
|
|
|
const parsed = JSON.parse(stored!);
|
|
expect(parsed.length).toBe(1);
|
|
expect(parsed[0].config.url).toBe('/api/v1/tracks');
|
|
});
|
|
|
|
it('should load queue from localStorage on initialization', async () => {
|
|
// Manually set localStorage
|
|
const queuedRequest: QueuedRequest = {
|
|
id: 'test-id',
|
|
config: {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
},
|
|
timestamp: Date.now(),
|
|
retryCount: 0,
|
|
priority: 'normal',
|
|
};
|
|
|
|
localStorageMock.setItem(
|
|
'veza_offline_queue',
|
|
JSON.stringify([queuedRequest]),
|
|
);
|
|
|
|
// Create new instance (would normally happen on import, but we'll check manually)
|
|
// Note: Since offlineQueue is a singleton, we need to check if it loads on init
|
|
// For this test, we'll verify the loadQueue logic works
|
|
const stored = localStorageMock.getItem('veza_offline_queue');
|
|
expect(stored).not.toBeNull();
|
|
});
|
|
|
|
it('should filter out old requests (>24 hours) on load', async () => {
|
|
const oldRequest: QueuedRequest = {
|
|
id: 'old-id',
|
|
config: {
|
|
method: 'POST',
|
|
url: '/api/v1/tracks',
|
|
},
|
|
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
|
|
retryCount: 0,
|
|
priority: 'normal',
|
|
};
|
|
|
|
const newRequest: QueuedRequest = {
|
|
id: 'new-id',
|
|
config: {
|
|
method: 'POST',
|
|
url: '/api/v1/playlists',
|
|
},
|
|
timestamp: Date.now(),
|
|
retryCount: 0,
|
|
priority: 'normal',
|
|
};
|
|
|
|
localStorageMock.setItem(
|
|
'veza_offline_queue',
|
|
JSON.stringify([oldRequest, newRequest]),
|
|
);
|
|
|
|
// The service should filter out old requests on load
|
|
// Since we can't easily test singleton initialization, we'll verify the logic
|
|
const stored = localStorageMock.getItem('veza_offline_queue');
|
|
const parsed = JSON.parse(stored!);
|
|
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
const filtered = parsed.filter(
|
|
(req: QueuedRequest) => req.timestamp > oneDayAgo,
|
|
);
|
|
|
|
expect(filtered.length).toBe(1);
|
|
expect(filtered[0].id).toBe('new-id');
|
|
});
|
|
});
|
|
});
|