Skip to content

Commit b8e1a6c

Browse files
marcstraubeclaude
andauthored
test: add cross-module integration tests (#59)
## Summary - Add integration tests for 4 key cross-module interactions: - **EncryptedStorage + StorageManager**: namespace isolation, no plaintext leaks, scoped clear - **RequestInterceptor + RetryQueue**: retry with backoff, auth header passthrough, retry events - **OfflineQueue + IndexedDB + NetworkStatus**: offline persistence, auto-sync on reconnect, priority ordering - **CacheManager + StorageManager**: two-tier cache, SWR with storage backup, eviction resilience Closes #5 ## Test plan - [x] All 4206 tests pass (42 new integration tests) - [x] No unhandled rejections - [x] ESLint, Prettier, TypeScript all clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 052bf66 commit b8e1a6c

4 files changed

Lines changed: 715 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { CacheManager } from '../../src/cache/index.js';
3+
import { StorageManager } from '../../src/storage/index.js';
4+
5+
/**
6+
* Integration: CacheManager + StorageManager
7+
*
8+
* Tests the layered caching pattern:
9+
* - CacheManager provides fast in-memory cache with TTL and SWR
10+
* - StorageManager provides persistent localStorage fallback
11+
* - Together they form a two-tier cache with offline support
12+
*/
13+
14+
interface UserData {
15+
readonly id: number;
16+
readonly name: string;
17+
}
18+
19+
function createMockLocalStorage(): Storage {
20+
const store = new Map<string, string>();
21+
return {
22+
get length() {
23+
return store.size;
24+
},
25+
clear(): void {
26+
store.clear();
27+
},
28+
getItem(key: string): string | null {
29+
return store.get(key) ?? null;
30+
},
31+
key(index: number): string | null {
32+
return Array.from(store.keys())[index] ?? null;
33+
},
34+
removeItem(key: string): void {
35+
store.delete(key);
36+
},
37+
setItem(key: string, value: string): void {
38+
store.set(key, value);
39+
},
40+
};
41+
}
42+
43+
describe('CacheManager + StorageManager', () => {
44+
let mockStorage: Storage;
45+
let originalLocalStorage: PropertyDescriptor | undefined;
46+
47+
beforeEach(() => {
48+
vi.useFakeTimers();
49+
mockStorage = createMockLocalStorage();
50+
originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage');
51+
Object.defineProperty(window, 'localStorage', {
52+
value: mockStorage,
53+
writable: true,
54+
configurable: true,
55+
});
56+
});
57+
58+
afterEach(() => {
59+
vi.useRealTimers();
60+
if (originalLocalStorage) {
61+
Object.defineProperty(window, 'localStorage', originalLocalStorage);
62+
}
63+
});
64+
65+
it('should use cache for fast access and storage for persistence', async () => {
66+
const cache = CacheManager.create<UserData>({ maxSize: 100 });
67+
const storage = StorageManager.create<UserData>({ prefix: 'users' });
68+
69+
const user: UserData = { id: 1, name: 'Alice' };
70+
71+
// Write to both tiers
72+
await cache.set('user:1', user, { ttl: 60_000 });
73+
storage.set('user1', user);
74+
75+
// Fast path: read from cache
76+
const cached = await cache.get('user:1');
77+
expect(cached?.value).toEqual(user);
78+
79+
// Persistence path: read from storage
80+
const persisted = storage.get('user1');
81+
expect(persisted).toEqual(user);
82+
83+
cache.destroy();
84+
});
85+
86+
it('should fall back to storage when cache misses', async () => {
87+
const cache = CacheManager.create<UserData>({ maxSize: 100 });
88+
const storage = StorageManager.create<UserData>({ prefix: 'users' });
89+
90+
const user: UserData = { id: 2, name: 'Bob' };
91+
storage.set('user2', user);
92+
93+
// Cache miss
94+
const cached = await cache.get('user:2');
95+
expect(cached).toBeUndefined();
96+
97+
// Fall back to storage
98+
const persisted = storage.get('user2');
99+
expect(persisted).toEqual(user);
100+
101+
// Populate cache from storage
102+
if (persisted !== null) {
103+
await cache.set('user:2', persisted, { ttl: 30_000 });
104+
}
105+
106+
// Now cache has it
107+
const reCached = await cache.get('user:2');
108+
expect(reCached?.value).toEqual(user);
109+
110+
cache.destroy();
111+
});
112+
113+
it('should serve stale cache while revalidating and update storage', async () => {
114+
const cache = CacheManager.create<UserData>({
115+
maxSize: 100,
116+
defaultStaleAfter: 100,
117+
defaultTtl: 60_000,
118+
});
119+
const storage = StorageManager.create<UserData>({ prefix: 'users' });
120+
121+
const staleUser: UserData = { id: 3, name: 'Charlie' };
122+
const freshUser: UserData = { id: 3, name: 'Charlie Updated' };
123+
124+
// Seed cache and storage
125+
await cache.set('user:3', staleUser);
126+
storage.set('user3', staleUser);
127+
128+
// Advance past staleAfter threshold
129+
vi.advanceTimersByTime(200);
130+
131+
// SWR: get stale value immediately, revalidate in background
132+
const result = await cache.get('user:3', {
133+
staleWhileRevalidate: true,
134+
revalidate: async () => {
135+
// Simulate API fetch + update storage
136+
storage.set('user3', freshUser);
137+
return freshUser;
138+
},
139+
});
140+
141+
expect(result?.value).toEqual(staleUser);
142+
expect(result?.isStale).toBe(true);
143+
144+
// Let revalidation complete
145+
await vi.advanceTimersByTimeAsync(10);
146+
147+
// Cache is now updated
148+
const updated = await cache.get('user:3');
149+
expect(updated?.value).toEqual(freshUser);
150+
151+
// Storage was also updated during revalidation
152+
expect(storage.get('user3')).toEqual(freshUser);
153+
154+
cache.destroy();
155+
});
156+
157+
it('should survive cache eviction with storage as backup', async () => {
158+
const cache = CacheManager.create<UserData>({ maxSize: 2 });
159+
const storage = StorageManager.create<UserData>({ prefix: 'users' });
160+
161+
const users: UserData[] = [
162+
{ id: 1, name: 'Alice' },
163+
{ id: 2, name: 'Bob' },
164+
{ id: 3, name: 'Charlie' },
165+
];
166+
167+
// Write all to storage, but cache only holds 2
168+
for (const user of users) {
169+
await cache.set(`user:${user.id}`, user);
170+
storage.set(`user${user.id}`, user);
171+
}
172+
173+
// Cache evicted the oldest entry
174+
const stats = cache.getStats();
175+
expect(stats.evictions).toBeGreaterThan(0);
176+
177+
// But storage still has all
178+
for (const user of users) {
179+
expect(storage.get(`user${user.id}`)).toEqual(user);
180+
}
181+
182+
cache.destroy();
183+
});
184+
185+
it('should coordinate invalidation across cache and storage', async () => {
186+
const cache = CacheManager.create<UserData>({ maxSize: 100 });
187+
const storage = StorageManager.create<UserData>({ prefix: 'users' });
188+
189+
const user: UserData = { id: 4, name: 'Diana' };
190+
191+
await cache.set('user:4', user, { tags: ['users'] });
192+
storage.set('user4', user);
193+
194+
// Invalidate by tag in cache
195+
const invalidated = await cache.invalidateByTag('users');
196+
expect(invalidated).toBe(1);
197+
198+
// Cache is empty
199+
expect(await cache.get('user:4')).toBeUndefined();
200+
201+
// Storage still has the data (manual cleanup needed)
202+
expect(storage.get('user4')).toEqual(user);
203+
204+
// Clean up storage separately
205+
storage.remove('user4');
206+
expect(storage.get('user4')).toBeNull();
207+
208+
cache.destroy();
209+
});
210+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { EncryptedStorage } from '../../src/encryption/index.js';
3+
import { StorageManager } from '../../src/storage/index.js';
4+
5+
/**
6+
* Integration: EncryptedStorage + StorageManager
7+
*
8+
* Both use localStorage independently. Tests verify:
9+
* - Namespace isolation (different prefixes don't interfere)
10+
* - Concurrent read/write without corruption
11+
* - Clear operations are scoped to prefix
12+
*/
13+
14+
function createMockLocalStorage(): Storage {
15+
const store = new Map<string, string>();
16+
return {
17+
get length() {
18+
return store.size;
19+
},
20+
clear(): void {
21+
store.clear();
22+
},
23+
getItem(key: string): string | null {
24+
return store.get(key) ?? null;
25+
},
26+
key(index: number): string | null {
27+
return Array.from(store.keys())[index] ?? null;
28+
},
29+
removeItem(key: string): void {
30+
store.delete(key);
31+
},
32+
setItem(key: string, value: string): void {
33+
store.set(key, value);
34+
},
35+
};
36+
}
37+
38+
describe('EncryptedStorage + StorageManager', () => {
39+
let mockStorage: Storage;
40+
let originalLocalStorage: PropertyDescriptor | undefined;
41+
42+
beforeEach(() => {
43+
mockStorage = createMockLocalStorage();
44+
originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
45+
Object.defineProperty(globalThis, 'localStorage', {
46+
value: mockStorage,
47+
writable: true,
48+
configurable: true,
49+
});
50+
});
51+
52+
afterEach(() => {
53+
if (originalLocalStorage) {
54+
Object.defineProperty(globalThis, 'localStorage', originalLocalStorage);
55+
}
56+
});
57+
58+
it('should coexist with separate prefixes without interference', async () => {
59+
const encrypted = await EncryptedStorage.create({
60+
password: 'secure-password-123',
61+
prefix: 'enc',
62+
storage: mockStorage,
63+
});
64+
const plain = StorageManager.create<string>({ prefix: 'plain' });
65+
66+
await encrypted.set('secret', 'classified');
67+
plain.set('public', 'visible');
68+
69+
expect(plain.get('public')).toBe('visible');
70+
expect(await encrypted.get<string>('secret')).toBe('classified');
71+
72+
encrypted.destroy();
73+
});
74+
75+
it('should not expose plaintext through plain storage manager', async () => {
76+
const encrypted = await EncryptedStorage.create({
77+
password: 'secure-password-123',
78+
prefix: 'enc',
79+
storage: mockStorage,
80+
});
81+
82+
const secret = { sensitive: true, message: 'top-secret' };
83+
await encrypted.set('secret', secret);
84+
85+
// Reading raw localStorage entries via plain StorageManager should never
86+
// return the original plaintext object
87+
const plain = StorageManager.create<unknown>({ prefix: 'enc' });
88+
const keys = plain.keys();
89+
90+
for (const key of keys) {
91+
const value = plain.get(key);
92+
// Value may be null (unparseable) or an opaque ciphertext string,
93+
// but never the original plaintext
94+
if (value !== null) {
95+
expect(value).not.toEqual(secret);
96+
expect(JSON.stringify(value)).not.toContain('top-secret');
97+
}
98+
}
99+
100+
encrypted.destroy();
101+
});
102+
103+
it('should scope clear operations to own prefix', async () => {
104+
const encrypted = await EncryptedStorage.create({
105+
password: 'secure-password-123',
106+
prefix: 'enc',
107+
storage: mockStorage,
108+
});
109+
const plain = StorageManager.create<string>({ prefix: 'plain' });
110+
111+
await encrypted.set('secret', 'data');
112+
plain.set('item', 'value');
113+
114+
plain.clear();
115+
116+
expect(plain.keys()).toHaveLength(0);
117+
expect(await encrypted.get<string>('secret')).toBe('data');
118+
119+
encrypted.destroy();
120+
});
121+
122+
it('should handle concurrent writes to shared localStorage', async () => {
123+
const encrypted = await EncryptedStorage.create({
124+
password: 'secure-password-123',
125+
prefix: 'enc',
126+
storage: mockStorage,
127+
});
128+
const plain = StorageManager.create<number>({ prefix: 'plain' });
129+
130+
await Promise.all([encrypted.set('a', 'encrypted-a'), encrypted.set('b', 'encrypted-b')]);
131+
plain.set('x', 1);
132+
plain.set('y', 2);
133+
134+
expect(await encrypted.get<string>('a')).toBe('encrypted-a');
135+
expect(await encrypted.get<string>('b')).toBe('encrypted-b');
136+
expect(plain.get('x')).toBe(1);
137+
expect(plain.get('y')).toBe(2);
138+
139+
encrypted.destroy();
140+
});
141+
});

0 commit comments

Comments
 (0)