diff --git a/package.json b/package.json index 62a7a61..4b30982 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build:worklets": "rollup -c", "clean": "rimraf build", "prepublishOnly": "npm run build", - "test": "vitest", + "test": "vitest run", "ci:test": "vitest run --no-color --run --typecheck" }, "keywords": [ diff --git a/src/__tests__/cache/basic.test.ts b/src/__tests__/cache/basic.test.ts new file mode 100644 index 0000000..3a31fc4 --- /dev/null +++ b/src/__tests__/cache/basic.test.ts @@ -0,0 +1,106 @@ +import { vi, expect, describe, it, beforeEach } from "vitest"; +import { AudioCache } from "../../cache"; +import { + createTestUrls, + createTestAudioData, + createMockAudioBuffer, + createCacheState, + mockResponses, + simulateError +} from "../mockUtils"; +import { audioContextMock } from "../../setupTests"; + +describe("AudioCache - Basic Operations", () => { + beforeEach(() => { + AudioCache.clearMemoryCache(); + vi.clearAllMocks(); + }); + + describe("LRU Cache Implementation", () => { + it("should store and retrieve decoded buffers", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache miss scenario + const cache = createCacheState.empty(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + // Mock successful fetch + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + // Mock successful decode + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + // First request should fetch and decode + const result1 = await AudioCache.getAudioBuffer(audioContextMock, url); + expect(result1).toBe(audioBuffer); + expect(fetch).toHaveBeenCalledTimes(1); + expect(audioContextMock.decodeAudioData).toHaveBeenCalledTimes(1); + expect(cache.put).toHaveBeenCalledTimes(2); // One for data, one for metadata + + // Second request should use cached buffer + const result2 = await AudioCache.getAudioBuffer(audioContextMock, url); + expect(result2).toBe(audioBuffer); + expect(fetch).toHaveBeenCalledTimes(1); // No additional fetch + expect(audioContextMock.decodeAudioData).toHaveBeenCalledTimes(1); // No additional decode + }); + + it("should evict least recently used items when cache is full", async () => { + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache + const cache = createCacheState.empty(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + // Mock fetch and decode for all requests + global.fetch = vi.fn().mockImplementation(() => + Promise.resolve(mockResponses.success(arrayBuffer)) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockImplementation(() => Promise.resolve(audioBuffer)); + + // Fill cache to its limit (DEFAULT_CACHE_SIZE = 100) + const urls = Array.from( + { length: 101 }, + (_, i) => createTestUrls.audio(i) + ); + + // Load all URLs + await Promise.all(urls.map(url => + AudioCache.getAudioBuffer(audioContextMock, url) + )); + + // Reset mocks to verify new fetch + vi.clearAllMocks(); + + // Request the first URL again - should have been evicted + await AudioCache.getAudioBuffer(audioContextMock, urls[0]); + + // Should need to fetch and decode again + expect(fetch).toHaveBeenCalledTimes(1); + expect(audioContextMock.decodeAudioData).toHaveBeenCalledTimes(1); + }); + + it("should handle cache initialization errors", async () => { + const url = createTestUrls.audio(1); + + // Simulate Cache API not available + global.caches = undefined; + + await expect( + AudioCache.getAudioBuffer(audioContextMock, url) + ).rejects.toThrow("Cache API is not supported"); + + // Restore Cache API + Object.defineProperty(global, 'caches', { + value: { open: vi.fn().mockResolvedValue(createCacheState.empty()) } + }); + }); + }); +}); diff --git a/src/__tests__/cache/network.test.ts b/src/__tests__/cache/network.test.ts new file mode 100644 index 0000000..a2b80f6 --- /dev/null +++ b/src/__tests__/cache/network.test.ts @@ -0,0 +1,164 @@ +import { vi, expect, describe, it, beforeEach } from "vitest"; +import { AudioCache } from "../../cache"; +import { + createTestUrls, + createTestAudioData, + createMockAudioBuffer, + mockResponses, + simulateError, + createCacheState, + mockNetworkConditions +} from "../mockUtils"; +import { audioContextMock } from "../../setupTests"; + +describe("AudioCache - Network Operations", () => { + beforeEach(() => { + AudioCache.clearMemoryCache(); + vi.clearAllMocks(); + }); + + describe("Fetch Operations", () => { + it("makes initial fetch request with correct headers", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + const cache = createCacheState.empty(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + const fetchSpy = vi.fn().mockResolvedValue( + mockResponses.success(arrayBuffer) + ); + global.fetch = fetchSpy; + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + expect(fetchSpy).toHaveBeenCalledWith(url, expect.any(Object)); + const requestInit = fetchSpy.mock.calls[0][1]; + expect(requestInit.headers).toBeDefined(); + }); + + it("handles 304 responses correctly", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache with existing data + const cache = createCacheState.withEntry( + url, + arrayBuffer, + { + url, + etag: 'W/"123"', + lastModified: new Date().toUTCString(), + timestamp: Date.now() + } + ); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + // Mock 304 response + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.notModified() + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + const result = await AudioCache.getAudioBuffer(audioContextMock, url); + expect(result).toBe(audioBuffer); + }); + + it("handles network errors gracefully", async () => { + const url = createTestUrls.audio(1); + + const cache = createCacheState.empty(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + simulateError.network(); + + await expect( + AudioCache.getAudioBuffer(audioContextMock, url) + ).rejects.toThrow('Network error'); + }); + + it("handles timeout errors", async () => { + const url = createTestUrls.audio(1); + + const cache = createCacheState.empty(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockImplementationOnce(mockNetworkConditions.timeout); + + await expect( + AudioCache.getAudioBuffer(audioContextMock, url) + ).rejects.toThrow('Network timeout'); + }); + }); + + describe("Cache Headers", () => { + it("sends correct cache headers when etag exists", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + const etag = 'W/"123"'; + + // Setup cache with existing etag + const cache = createCacheState.withEntry( + url, + arrayBuffer, + { + url, + etag, + lastModified: new Date().toUTCString(), + timestamp: Date.now() - (25 * 60 * 60 * 1000) // Make it 25 hours old to force revalidation + } + ); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + const fetchSpy = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + global.fetch = fetchSpy; + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + expect(fetchSpy).toHaveBeenCalled(); + const requestInit = fetchSpy.mock.calls[0][1]; + expect(requestInit.headers.get('If-None-Match')).toBe(etag); + }); + + it("updates stored headers after successful fetch", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + const newEtag = 'W/"456"'; + + const cache = createCacheState.empty(); + const cachePutSpy = vi.spyOn(cache, 'put'); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer, { 'ETag': newEtag }) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Verify metadata was stored with new ETag + const metadataCall = cachePutSpy.mock.calls.find( + call => call[0] === `${url}:meta` + ); + expect(metadataCall).toBeDefined(); + const storedMetadata = await metadataCall[1].json(); + expect(storedMetadata.etag).toBe(newEtag); + }); + }); +}); diff --git a/src/__tests__/cache/storage.test.ts b/src/__tests__/cache/storage.test.ts new file mode 100644 index 0000000..1f611a3 --- /dev/null +++ b/src/__tests__/cache/storage.test.ts @@ -0,0 +1,323 @@ +import { vi, expect, describe, it, beforeEach } from "vitest"; +import { AudioCache } from "../../cache"; +import { + createTestUrls, + createTestAudioData, + createMockAudioBuffer, + mockResponses, + simulateError, + createCacheState +} from "../mockUtils"; +import { audioContextMock } from "../../setupTests"; + +describe("AudioCache - Cache Storage", () => { + beforeEach(() => { + AudioCache.clearMemoryCache(); + vi.clearAllMocks(); + }); + + describe("Cache API Integration", () => { + it("stores response and metadata separately", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + const cache = createCacheState.empty(); + const cachePutSpy = vi.spyOn(cache, 'put'); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Verify both data and metadata were stored + expect(cachePutSpy).toHaveBeenCalledTimes(2); + const dataPut = cachePutSpy.mock.calls.find(call => call[0] === url); + const metaPut = cachePutSpy.mock.calls.find(call => call[0] === `${url}:meta`); + + expect(dataPut).toBeDefined(); + expect(metaPut).toBeDefined(); + + // Verify metadata structure + const metadata = await metaPut[1].json(); + expect(metadata).toEqual(expect.objectContaining({ + url: url, + etag: expect.any(String), + timestamp: expect.any(Number) + })); + }); + + it("handles missing Cache API", async () => { + const url = createTestUrls.audio(1); + + // Simulate Cache API not available + const originalCaches = global.caches; + global.caches = undefined; + + await expect( + AudioCache.getAudioBuffer(audioContextMock, url) + ).rejects.toThrow("Cache API is not supported"); + + // Restore Cache API + global.caches = originalCaches; + }); + + it("handles storage errors", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache with storage error + const cache = simulateError.storage(); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + // Should still return audio buffer even if caching fails + const result = await AudioCache.getAudioBuffer(audioContextMock, url); + expect(result).toBe(audioBuffer); + }); + + it("cleans up partial entries", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + const cache = createCacheState.empty(); + const cacheDeleteSpy = vi.spyOn(cache, 'delete'); + vi.spyOn(cache, 'put').mockRejectedValueOnce(new Error("Storage failed")); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Should attempt to clean up both entries + expect(cacheDeleteSpy).toHaveBeenCalledWith(url); + expect(cacheDeleteSpy).toHaveBeenCalledWith(`${url}:meta`); + }); + + it("validates stored data", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache with corrupted data + const cache = createCacheState.corrupted(url); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + const result = await AudioCache.getAudioBuffer(audioContextMock, url); + + // Should fetch fresh data when cache is corrupted + expect(fetch).toHaveBeenCalledTimes(1); + expect(result).toBe(audioBuffer); + }); + }); + + describe("Metadata Management", () => { + it("stores complete metadata", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + const etag = 'W/"123"'; + const lastModified = new Date().toUTCString(); + + const cache = createCacheState.empty(); + const cachePutSpy = vi.spyOn(cache, 'put'); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer, { + 'ETag': etag, + 'Last-Modified': lastModified + }) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + const metaPut = cachePutSpy.mock.calls.find(call => call[0] === `${url}:meta`); + expect(metaPut).toBeDefined(); + const metadata = await metaPut[1].json(); + expect(metadata).toEqual({ + url, + etag, + lastModified, + timestamp: expect.any(Number) + }); + }); + + it("updates timestamps", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + const originalTimestamp = Date.now() - 1000; + + // Create a working mock cache with a Map to actually store stuff + const storedData = new Map(); + const cache = { + match: vi.fn(key => Promise.resolve(storedData.get(key))), + put: vi.fn((key, value) => { + storedData.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn() + }; + + // Set up initial cache state + const initialMetadata = { + url, + etag: 'W/"123"', + lastModified: new Date().toUTCString(), + timestamp: originalTimestamp + }; + + // Store initial data in our mock cache + await cache.put(url, mockResponses.success(arrayBuffer)); + await cache.put( + `${url}:meta`, + new Response(JSON.stringify(initialMetadata), { + headers: { "Content-Type": "application/json" } + }) + ); + + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + // Mock 304 response to trigger timestamp update + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.notModified({ + 'Date': new Date().toUTCString() + }) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Get the updated metadata + const metaResponse = await cache.match(`${url}:meta`); + const updatedMetadata = await metaResponse.json(); + + // Verify timestamp was updated + expect(updatedMetadata.timestamp).toBeGreaterThan(originalTimestamp); + }); + + it("handles missing metadata", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache with data but no metadata + const cache = createCacheState.empty(); + vi.spyOn(cache, 'match').mockImplementation((key: string) => { + if (key === url) { + return Promise.resolve(mockResponses.success(arrayBuffer)); + } + return Promise.resolve(undefined); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Should trigger a new fetch since metadata is missing + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("validates metadata structure", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + // Setup cache with invalid metadata + const cache = createCacheState.empty(); + vi.spyOn(cache, 'match').mockImplementation((key: string) => { + if (key === url) { + return Promise.resolve(mockResponses.success(arrayBuffer)); + } + if (key === `${url}:meta`) { + return Promise.resolve(new Response(JSON.stringify({ + invalid: "metadata" + }))); + } + return Promise.resolve(undefined); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Should fetch fresh data when metadata is invalid + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("cleans up invalid metadata", async () => { + const url = createTestUrls.audio(1); + const arrayBuffer = createTestAudioData.small(); + const audioBuffer = createMockAudioBuffer(); + + const cache = createCacheState.empty(); + const cacheDeleteSpy = vi.spyOn(cache, 'delete'); + vi.spyOn(cache, 'match').mockImplementation((key: string) => { + if (key === url) { + return Promise.resolve(mockResponses.success(arrayBuffer)); + } + if (key === `${url}:meta`) { + return Promise.resolve(new Response("invalid json")); + } + return Promise.resolve(undefined); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + global.fetch = vi.fn().mockResolvedValueOnce( + mockResponses.success(arrayBuffer) + ); + + vi.spyOn(audioContextMock, "decodeAudioData") + .mockResolvedValueOnce(audioBuffer); + + await AudioCache.getAudioBuffer(audioContextMock, url); + + // Should clean up both entries when metadata is invalid + expect(cacheDeleteSpy).toHaveBeenCalledWith(url); + expect(cacheDeleteSpy).toHaveBeenCalledWith(`${url}:meta`); + }); + }); +}); diff --git a/src/__tests__/mockUtils.ts b/src/__tests__/mockUtils.ts new file mode 100644 index 0000000..e9dd805 --- /dev/null +++ b/src/__tests__/mockUtils.ts @@ -0,0 +1,163 @@ +import { AudioBuffer, AudioContext } from "standardized-audio-context-mock"; +import { vi } from "vitest"; + +export const createMockArrayBuffer = (size = 8) => new ArrayBuffer(size); + +export const createMockAudioBuffer = (options: { + length?: number; + sampleRate?: number; + numberOfChannels?: number; +} = {}) => + new AudioBuffer({ + length: options.length || 100, + sampleRate: options.sampleRate || 44100, + numberOfChannels: options.numberOfChannels || 1 + }); + +export const mockResponses = { + success: (arrayBuffer: ArrayBuffer, headers: Record = {}) => ({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(arrayBuffer), + clone: function() { + return mockResponses.success(arrayBuffer, headers); + }, + headers: new Headers({ + 'ETag': 'W/"123"', + 'Last-Modified': new Date().toUTCString(), + 'Date': new Date().toUTCString(), + ...headers + }) + }), + + notModified: (headers: Record = {}) => ({ + ok: true, + status: 304, + clone: function() { return this; }, + headers: new Headers(headers) + }), + + error: (status = 404, statusText = 'Not Found', headers: Record = {}) => ({ + ok: false, + status, + statusText, + clone: function() { + return mockResponses.error(status, statusText, headers); + }, + headers: new Headers({ + 'Date': new Date().toUTCString(), + ...headers + }) + }) +}; + +export const mockCache = () => ({ + match: vi.fn(), + put: vi.fn(), + delete: vi.fn() +}); + +export const mockCacheStorage = () => ({ + open: vi.fn().mockResolvedValue(mockCache()) +}); + +// Helper to simulate network conditions +export const mockNetworkConditions = { + timeout: () => new Promise((_, reject) => { + reject(new Error('Network timeout')); + }), + offline: () => Promise.reject(new Error('Network error: offline')), + slow: async () => { + await new Promise(resolve => setTimeout(resolve, 2000)); + return mockResponses.success(createMockArrayBuffer()); + } +}; + +// Helper to create test audio data +export const createTestAudioData = { + small: () => createMockArrayBuffer(1024), // 1KB + medium: () => createMockArrayBuffer(1024 * 1024), // 1MB + large: () => createMockArrayBuffer(10 * 1024 * 1024), // 10MB + invalid: () => new ArrayBuffer(0) +}; + +// Helper to create test URLs +export const createTestUrls = { + audio: (id: number | string) => `https://example.com/audio${id}.mp3`, + dataUrl: (content: string) => `data:audio/wav;base64,${btoa(content)}`, + invalid: () => 'invalid://url', + expired: (id: number | string) => `https://example.com/expired${id}.mp3` +}; + +// Helper to create cache metadata +export const createMetadata = (url: string, options: { + etag?: string; + lastModified?: string; + timestamp?: number; +} = {}) => ({ + url, + etag: options.etag || 'W/"123"', + lastModified: options.lastModified || new Date().toUTCString(), + timestamp: options.timestamp || Date.now() +}); + +// Helper to simulate cache states +export const createCacheState = { + empty: () => mockCache(), + withEntry: (url: string, arrayBuffer: ArrayBuffer, metadata: any) => { + const cache = mockCache(); + cache.match.mockImplementation((key: string) => { + if (key === url) { + return Promise.resolve(mockResponses.success(arrayBuffer)); + } + if (key === `${url}:meta`) { + return Promise.resolve(new Response(JSON.stringify(metadata))); + } + return Promise.resolve(undefined); + }); + return cache; + }, + corrupted: (url: string) => { + const cache = mockCache(); + cache.match.mockImplementation((key: string) => { + if (key === url) { + return Promise.resolve(new Response(null, { status: 500 })); + } + return Promise.resolve(undefined); + }); + return cache; + }, + expired: (url: string, arrayBuffer: ArrayBuffer) => { + const expiredMetadata = createMetadata(url, { + timestamp: Date.now() - (25 * 60 * 60 * 1000) // 25 hours ago + }); + return createCacheState.withEntry(url, arrayBuffer, expiredMetadata); + } +}; + +// Helper to simulate error conditions +export const simulateError = { + network: () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + }, + decode: (context: AudioContext) => { + vi.spyOn(context, 'decodeAudioData') + .mockRejectedValue(new Error('Decode error')); + }, + cache: () => { + const error = new Error('Cache error'); + return { + match: vi.fn().mockRejectedValue(error), + put: vi.fn().mockRejectedValue(error), + delete: vi.fn().mockRejectedValue(error) + }; + }, + storage: () => { + const error = new DOMException('Quota exceeded', 'QuotaExceededError'); + return { + match: vi.fn().mockRejectedValue(error), + put: vi.fn().mockRejectedValue(error), + delete: vi.fn() + }; + } +}; diff --git a/src/cache.ts b/src/cache.ts index d84a323..6f3e7de 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,209 +1,339 @@ -import type { AudioContext } from './context'; -import { ICache } from './interfaces/ICache'; +import type { AudioContext } from "./context"; +import { ICache } from "./interfaces/ICache"; class LRUCache { - private maxSize: number; - private cache: Map; - - constructor(maxSize: number) { - this.maxSize = maxSize; - this.cache = new Map(); - } - - get(key: K): V | undefined { - if (!this.cache.has(key)) return undefined; - const value = this.cache.get(key)!; - this.cache.delete(key); - this.cache.set(key, value); - return value; - } - - set(key: K, value: V): void { - if (this.cache.has(key)) { - this.cache.delete(key); - } else if (this.cache.size >= this.maxSize) { - const firstKey = this.cache.keys().next().value; - this.cache.delete(firstKey); - } - this.cache.set(key, value); + private maxSize: number; + private cache: Map; + + constructor(maxSize: number) { + this.maxSize = maxSize; + this.cache = new Map(); + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) return undefined; + const value = this.cache.get(key)!; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } } + this.cache.set(key, value); + } - has(key: K): boolean { - return this.cache.has(key); - } + has(key: K): boolean { + return this.cache.has(key); + } } - interface CacheMetadata { - url: string; - etag?: string; - lastModified?: string; - timestamp: number; + url: string; + etag?: string; + lastModified?: string; + timestamp: number; } const DEFAULT_CACHE_SIZE = 100; -export class AudioCache implements ICache { - private static pendingRequests = new Map>(); - private static decodedBuffers = new LRUCache(DEFAULT_CACHE_SIZE); - private static cacheExpirationTime: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds +export class AudioCache { + private static pendingRequests = new Map>(); + private static decodedBuffers = new LRUCache( + DEFAULT_CACHE_SIZE + ); + private static cacheExpirationTime: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - public static setCacheExpirationTime(time: number): void { - this.cacheExpirationTime = time; - } + public static setCacheExpirationTime(time: number): void { + this.cacheExpirationTime = time; + } - private static async openCache(): Promise { - try { - return await caches.open('audio-cache'); - } catch (error) { - console.error('Failed to open cache:', error); - throw error; - } + private static async openCache(): Promise { + if (typeof caches === "undefined") { + throw new Error("Cache API is not supported in this environment."); } - - private static async getBufferFromCache(url: string, cache: Cache): Promise { - try { - const response = await cache.match(url); - if (response && response.ok) { - return await response.arrayBuffer(); - } - return null; - } catch (error) { - console.error('Failed to get data from cache:', error); - return null; - } + try { + return await caches.open("audio-cache"); + } catch (error) { + console.error("Failed to open cache:", error); + throw error; } - - private static async fetchAndCacheBuffer(url: string, cache: Cache, etag?: string, lastModified?: string): Promise { - try { - const headers = new Headers(); - if (etag) headers.append('If-None-Match', etag); - if (lastModified) headers.append('If-Modified-Since', lastModified); - - const fetchResponse = await fetch(url, { headers }); - const responseClone = fetchResponse.clone(); - - if (fetchResponse.status === 200) { - const newEtag = fetchResponse.headers.get('ETag') || undefined; - const newLastModified = fetchResponse.headers.get('Last-Modified') || undefined; - const cacheData: CacheMetadata = { - url, - etag: newEtag, - lastModified: newLastModified, - timestamp: Date.now() - }; - - await cache.put(url, responseClone); - await cache.put(url + ':meta', new Response(JSON.stringify(cacheData), { headers: { 'Content-Type': 'application/json' } })); - } else if (fetchResponse.status === 304) { - const cachedResponse = await cache.match(url); - if (cachedResponse) { - return await cachedResponse.arrayBuffer(); - } - } - - return await fetchResponse.arrayBuffer(); - } catch (error) { - console.error('Failed to fetch and cache data:', error); - throw error; - } + } + + private static async getBufferFromCache( + url: string, + cache: Cache + ): Promise { + try { + const response = await cache.match(url); + const metaResponse = await cache.match(url + ":meta"); + + if (!response || !metaResponse) { + // Invalidate both cache entries if one is missing + await cache.delete(url); + await cache.delete(url + ":meta"); + return null; + } + + if (!response.ok) { + throw new Error(`Cached response not ok: ${response.status}`); + } + + const metadata = await metaResponse.json(); + if (!metadata || typeof metadata.timestamp !== "number") { + throw new Error("Invalid cache metadata"); + } + + return await response.arrayBuffer(); + } catch (error) { + console.error(`Failed to get data from cache for URL ${url}:`, error); + await cache.delete(url); + await cache.delete(url + ":meta"); + return null; } + } + + private static async fetchAndCacheBuffer( + url: string, + cache: Cache, + etag?: string, + lastModified?: string + ): Promise { + try { + const headers = new Headers(); + if (etag) headers.append("If-None-Match", etag); + if (lastModified) headers.append("If-Modified-Since", lastModified); + + const fetchResponse = await fetch(url, { headers }); + + if (fetchResponse.status === 200) { + const responseClone = fetchResponse.clone(); + const newEtag = fetchResponse.headers.get("ETag") || undefined; + const newLastModified = + fetchResponse.headers.get("Last-Modified") || undefined; + const cacheData: CacheMetadata = { + url, + etag: newEtag, + lastModified: newLastModified, + timestamp: Date.now(), + }; - private static async decodeAudioData(context: AudioContext, arrayBuffer: ArrayBuffer): Promise { try { - return await context.decodeAudioData(arrayBuffer); + const serverDate = fetchResponse.headers.get("Date"); + const timestamp = serverDate + ? new Date(serverDate).getTime() + : Date.now(); + const cacheData: CacheMetadata = { + url, + etag: newEtag, + lastModified: newLastModified, + timestamp, + }; + + await Promise.all([ + cache.put(url, responseClone), + cache.put( + url + ":meta", + new Response(JSON.stringify(cacheData), { + headers: { "Content-Type": "application/json" }, + }) + ), + ]); } catch (error) { - console.error('Failed to decode audio data:', error); - throw error; + console.error("Failed to store in cache:", error); + // Attempt to clean up partial cache entries + await cache.delete(url); + await cache.delete(url + ":meta"); } - } - - private static async getMetadataFromCache(url: string, cache: Cache): Promise { - try { - const metaResponse = await cache.match(url + ':meta'); - if (metaResponse && metaResponse.ok) { - return await metaResponse.json(); - } - return null; - } catch (error) { - console.error('Failed to get metadata from cache:', error); - return null; - } - } - - public async getAudioBuffer(context: AudioContext, url: string): Promise { - // Check if the decoded buffer is already available - if (AudioCache.decodedBuffers.has(url)) { - return AudioCache.decodedBuffers.get(url)!; + return await fetchResponse.arrayBuffer(); + } + + if (fetchResponse.status === 304) { + // Get existing cached response + const cachedResponse = await cache.match(url); + if (!cachedResponse) { + throw new Error("Cached response missing despite 304 Not Modified"); } - // handle data: urls - if (url.startsWith('data:')) { - const base64Data = url.split(',')[1]; - const buffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)).buffer; - const audioBuffer = await AudioCache.decodeAudioData(context, buffer); - AudioCache.decodedBuffers.set(url, audioBuffer); - return audioBuffer; + // Update metadata timestamp on successful revalidation + const serverDate = fetchResponse.headers.get("Date"); + const timestamp = serverDate + ? new Date(serverDate).getTime() + : Date.now(); + const cacheData: CacheMetadata = { + url, + etag, + lastModified, + timestamp, + }; + + // Store updated metadata before returning cached data + await cache.put( + url + ":meta", + new Response(JSON.stringify(cacheData), { + headers: { "Content-Type": "application/json" }, + }) + ); + + return await cachedResponse.arrayBuffer(); + } + + throw new Error(`Unexpected response status: ${fetchResponse.status}`); + } catch (error) { + console.error("Failed to fetch and cache data:", error); + throw error; + } + } + + private static async decodeAudioData( + context: AudioContext, + arrayBuffer: ArrayBuffer + ): Promise { + try { + return await context.decodeAudioData(arrayBuffer); + } catch (error) { + console.error("Failed to decode audio data:", error); + throw error; + } + } + + private static async getMetadataFromCache( + url: string, + cache: Cache + ): Promise { + try { + const metaResponse = await cache.match(url + ":meta"); + if (!metaResponse || !metaResponse.ok) { + return null; + } + try { + const metadata = await metaResponse.json(); + if (!metadata || typeof metadata.timestamp !== "number") { + throw new Error("Invalid metadata structure"); } + return metadata; + } catch (error) { + console.error("Failed to get metadata from cache:", error); + // Clean up invalid metadata + await cache.delete(url); + await cache.delete(url + ":meta"); + return null; + } + } catch (error) { + console.error("Failed to get metadata from cache:", error); + return null; + } + } + + public static async getAudioBuffer( + context: AudioContext, + url: string + ): Promise { + // Check if the decoded buffer is already available + if (this.decodedBuffers.has(url)) { + return this.decodedBuffers.get(url)!; + } - const cache = await AudioCache.openCache(); + // handle data: urls + if (url.startsWith("data:")) { + const matches = url.match(/^data:(.*?)(;base64)?,(.*)$/); + if (!matches) { + throw new Error("Invalid data URL format"); + } + const isBase64 = !!matches[2]; + const data = matches[3]; + const binaryString = isBase64 ? atob(data) : decodeURIComponent(data); + const buffer = Uint8Array.from(binaryString, (c) => + c.charCodeAt(0) + ).buffer; + const audioBuffer = await this.decodeAudioData(context, buffer); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; + } - // First, check if there's a pending request. - let pendingRequest = AudioCache.pendingRequests.get(url); - if (pendingRequest) { - return pendingRequest; - } + // Use a helper method to get or create the pending request + const pendingRequest = this.getOrCreatePendingRequest(url, async () => { + const cache = await this.openCache(); + const metadata = await this.getMetadataFromCache(url, cache); + let shouldFetch = false; - // Check for cached metadata (ETag, Last-Modified) - const metadata = await AudioCache.getMetadataFromCache(url, cache); - let shouldFetch = !metadata; - - if (metadata) { - if (metadata.etag || metadata.lastModified) { - // Use ETag or Last-Modified for revalidation - shouldFetch = false; - } else if (metadata.timestamp) { - // If timestamp exists, use expiration time - shouldFetch = (Date.now() - metadata.timestamp) > AudioCache.cacheExpirationTime; - } else { - // If no timestamp, ETag, or Last-Modified, assume it's expired - shouldFetch = true; - } + if (metadata) { + if (Date.now() - metadata.timestamp > this.cacheExpirationTime) { + // Cache has expired, need to revalidate + shouldFetch = true; + } else { + // Cache is valid, use cached data + shouldFetch = false; } - - if (shouldFetch) { - // If it's not in the cache or needs revalidation, fetch and cache it. - pendingRequest = AudioCache.fetchAndCacheBuffer(url, cache, metadata?.etag, metadata?.lastModified) - .then(arrayBuffer => AudioCache.decodeAudioData(context, arrayBuffer)) - .then(audioBuffer => { - AudioCache.decodedBuffers.set(url, audioBuffer); - return audioBuffer; - }) - .finally(() => { - AudioCache.pendingRequests.delete(url); // Cleanup pending request - }); - AudioCache.pendingRequests.set(url, pendingRequest); - - return pendingRequest; + } else { + // No metadata, need to fetch + shouldFetch = true; + } + + if (shouldFetch) { + // If it's not in the cache or needs revalidation, fetch and cache it. + const arrayBuffer = await this.fetchAndCacheBuffer( + url, + cache, + metadata?.etag, + metadata?.lastModified + ); + const audioBuffer = await this.decodeAudioData(context, arrayBuffer); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; + } else { + // Use cached version + const cachedBuffer = await this.getBufferFromCache(url, cache); + if (cachedBuffer) { + const audioBuffer = await this.decodeAudioData(context, cachedBuffer); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; } else { - // Use cached version - const cachedBuffer = await AudioCache.getBufferFromCache(url, cache); - if (cachedBuffer) { - const audioBuffer = await AudioCache.decodeAudioData(context, cachedBuffer); - AudioCache.decodedBuffers.set(url, audioBuffer); - return audioBuffer; - } + // Cached data missing; need to fetch + const arrayBuffer = await this.fetchAndCacheBuffer(url, cache); + const audioBuffer = await this.decodeAudioData(context, arrayBuffer); + this.decodedBuffers.set(url, audioBuffer); + return audioBuffer; } - - // If we reach here, something went wrong - throw new Error('Failed to retrieve audio buffer'); + } + }); + + return pendingRequest; + } + + private static getOrCreatePendingRequest( + url: string, + createRequest: () => Promise + ): Promise { + let pendingRequest = this.pendingRequests.get(url); + if (!pendingRequest) { + const requestPromise = (async () => { + try { + const result = await createRequest(); + return result; + } catch (error) { + console.error(`Error processing request for URL ${url}:`, error); + throw error; + } finally { + // Only remove from pending requests after completely settled + this.pendingRequests.delete(url); + } + })(); + this.pendingRequests.set(url, requestPromise); + return requestPromise; } + return pendingRequest; + } - - public clearMemoryCache(): void { - AudioCache.decodedBuffers = new LRUCache(DEFAULT_CACHE_SIZE); - AudioCache.pendingRequests.clear(); - } + public static clearMemoryCache(): void { + this.decodedBuffers = new LRUCache(DEFAULT_CACHE_SIZE); + this.pendingRequests.clear(); + } } - - diff --git a/src/setupTests.ts b/src/setupTests.ts index 8f95fa5..5e25607 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,8 +1,12 @@ import { AudioContext, AudioBuffer } from "standardized-audio-context-mock"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { Cacophony } from "./cacophony"; + import { ICache } from './interfaces/ICache'; +import { mockCacheStorage } from "./__tests__/mockUtils"; + + export let cacophony: Cacophony; export let audioContextMock: AudioContext; @@ -13,6 +17,8 @@ const mockCache: ICache = { beforeAll(() => { vi.useFakeTimers(); + // Mock Cache API + global.caches = mockCacheStorage(); }); afterAll(() => { @@ -22,7 +28,11 @@ afterAll(() => { beforeEach(() => { vi.resetAllMocks(); audioContextMock = new AudioContext(); + cacophony = new Cacophony(audioContextMock, mockCache); + // Reset all mocks + vi.clearAllMocks(); + }); afterEach(() => {