diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 90ce98c..d641564 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -28,16 +28,21 @@ export interface StorageQuota { class Storage { private db: IDBDatabase | null = null; + private initPromise: Promise | null = null; /** - * Initialize IndexedDB + * Initialize IndexedDB (idempotent — safe to call multiple times) */ async init(): Promise { - return new Promise((resolve, reject) => { + if (this.db) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { logger.error('IndexedDB initialization failed'); + this.initPromise = null; reject(request.error); }; @@ -92,6 +97,17 @@ class Storage { logger.info('Database upgraded to version ' + DB_VERSION); }; }); + + return this.initPromise; + } + + /** + * Ensure database is initialized before performing operations + */ + private async ensureInit(): Promise { + if (!this.db) { + await this.init(); + } } /** @@ -101,13 +117,9 @@ class Storage { storeName: K, value: StorageObjects[K], ): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readwrite'); + const transaction = this.db!.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); const request = store.put(value); @@ -123,13 +135,9 @@ class Storage { storeName: K, key: IDBValidKey, ): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readonly'); + const transaction = this.db!.transaction([storeName], 'readonly'); const store = transaction.objectStore(storeName); const request = store.get(key); @@ -144,13 +152,9 @@ class Storage { async getAll( storeName: K, ): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readonly'); + const transaction = this.db!.transaction([storeName], 'readonly'); const store = transaction.objectStore(storeName); const request = store.getAll(); @@ -166,13 +170,9 @@ class Storage { storeName: K, key: IDBValidKey, ): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readwrite'); + const transaction = this.db!.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); const request = store.delete(key); @@ -185,13 +185,9 @@ class Storage { * Clear all objects from a store */ async clear(storeName: K): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readwrite'); + const transaction = this.db!.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); const request = store.clear(); @@ -200,8 +196,6 @@ class Storage { }); } - /** - /** * Bulk write operations for OPML import * More efficient than multiple put() calls @@ -210,13 +204,11 @@ class Storage { storeName: K, values: StorageObjects[K][], ): Promise { - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } + await this.ensureInit(); + if (values.length === 0) return; - const transaction = this.db.transaction([storeName], 'readwrite'); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); let completed = 0; @@ -244,11 +236,6 @@ class Storage { }); transaction.onerror = () => reject(transaction.error); - - // Handle empty array - if (values.length === 0) { - resolve(); - } }); } @@ -306,13 +293,9 @@ class Storage { indexName: string, value: IDBValidKey, ): Promise { + await this.ensureInit(); return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([storeName], 'readonly'); + const transaction = this.db!.transaction([storeName], 'readonly'); const store = transaction.objectStore(storeName); const index = store.index(indexName); const request = index.getAll(value); diff --git a/tests/integration/feedServiceInit.test.ts b/tests/integration/feedServiceInit.test.ts new file mode 100644 index 0000000..5614a59 --- /dev/null +++ b/tests/integration/feedServiceInit.test.ts @@ -0,0 +1,56 @@ +/** + * Integration test: Feed Service with auto-initialization + * Ensures subscribeFeed and getArticlesForFeed work without explicit storage.init() + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { storage } from '@lib/storage'; +import { subscribeFeed, getArticlesForFeed } from '@services/feedService'; +import { server } from '../setup'; +import { http, HttpResponse } from 'msw'; +import { RSS2_SAMPLE } from '../fixtures/feeds'; + +describe('Feed Service auto-initialization', () => { + beforeEach(async () => { + // Only clear data, relying on auto-init inside storage methods + await storage.clear('feeds'); + await storage.clear('articles'); + }); + + it('should subscribe to a feed without explicit storage.init()', async () => { + server.use( + http.get('https://example.com/feed.xml', () => { + return HttpResponse.text(RSS2_SAMPLE, { + headers: { 'Content-Type': 'application/rss+xml' }, + }); + }) + ); + + const result = await subscribeFeed('https://example.com/feed.xml'); + + expect(result.success).toBe(true); + expect(result.feed).toBeDefined(); + expect(result.feed?.title).toBe('Sample RSS Feed'); + + // Verify persistence + const feeds = await storage.getAll('feeds'); + expect(feeds).toHaveLength(1); + }); + + it('should load articles for a feed without explicit storage.init()', async () => { + server.use( + http.get('https://example.com/feed.xml', () => { + return HttpResponse.text(RSS2_SAMPLE, { + headers: { 'Content-Type': 'application/rss+xml' }, + }); + }) + ); + + const result = await subscribeFeed('https://example.com/feed.xml'); + expect(result.success).toBe(true); + + const articles = await getArticlesForFeed(result.feed!.id); + expect(articles.length).toBeGreaterThan(0); + expect(articles[0].feedId).toBe(result.feed!.id); + }); +}); diff --git a/tests/unit/storage.test.ts b/tests/unit/storage.test.ts index 16950b2..99bf68a 100644 --- a/tests/unit/storage.test.ts +++ b/tests/unit/storage.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for Storage bulk operations + * Unit tests for Storage operations and auto-initialization */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -14,6 +14,69 @@ describe('Storage', () => { await storage.clear('articles'); }); + describe('auto-initialization', () => { + it('should auto-initialize when calling put without explicit init', async () => { + // Create a fresh Storage instance to test auto-init + const { storage: freshStorage } = await import('@lib/storage'); + const feed: Feed = { + id: 'auto-init-feed', + url: 'https://example.com/feed.xml', + title: 'Auto Init Feed', + description: 'Test', + lastFetchedAt: null, + refreshIntervalMinutes: 60, + paused: false, + errorCount: 0, + createdAt: new Date(), + deletedAt: null, + }; + + // Should NOT throw "Database not initialized" + await freshStorage.put('feeds', feed); + const stored = await freshStorage.get('feeds', 'auto-init-feed'); + expect(stored).toBeDefined(); + expect(stored?.title).toBe('Auto Init Feed'); + }); + + it('should auto-initialize when calling getAll without explicit init', async () => { + const { storage: freshStorage } = await import('@lib/storage'); + // Should NOT throw "Database not initialized" + const feeds = await freshStorage.getAll('feeds'); + expect(Array.isArray(feeds)).toBe(true); + }); + + it('should auto-initialize when calling get without explicit init', async () => { + const { storage: freshStorage } = await import('@lib/storage'); + // Should NOT throw "Database not initialized" + const result = await freshStorage.get('feeds', 'non-existent'); + expect(result).toBeUndefined(); + }); + + it('should handle multiple concurrent init calls safely', async () => { + const { storage: freshStorage } = await import('@lib/storage'); + // Call init multiple times concurrently + await expect( + Promise.all([ + freshStorage.init(), + freshStorage.init(), + freshStorage.init(), + ]) + ).resolves.not.toThrow(); + }); + + it('should handle concurrent storage operations without explicit init', async () => { + const { storage: freshStorage } = await import('@lib/storage'); + // Multiple operations in parallel should all auto-init safely + const results = await Promise.all([ + freshStorage.getAll('feeds'), + freshStorage.getAll('articles'), + freshStorage.getAll('categories'), + ]); + expect(results).toHaveLength(3); + results.forEach(r => expect(Array.isArray(r)).toBe(true)); + }); + }); + describe('bulkPut', () => { it('should insert multiple items efficiently', async () => { const feeds: Feed[] = Array.from({ length: 10 }, (_, i) => ({