summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorGravatar amrkmn 2025-12-26 22:39:23 +0800
committerGravatar amrkmn 2025-12-26 22:39:23 +0800
commit32ca410f4edbff578d71781d943c41573912f476 (patch)
tree49f7e1e5602657d23945082fe273fc4802959a40 /tests
Initial commitmain
Diffstat (limited to 'tests')
-rw-r--r--tests/cache-files.test.ts100
-rw-r--r--tests/cache-format.test.ts27
-rw-r--r--tests/cache-lock.test.ts141
-rw-r--r--tests/cache-manifest.test.ts76
-rw-r--r--tests/cache-metadata.test.ts21
-rw-r--r--tests/cache-utils.test.ts23
-rw-r--r--tests/debounce.test.ts60
-rw-r--r--tests/logger.test.ts26
-rw-r--r--tests/search-utils.test.ts24
9 files changed, 498 insertions, 0 deletions
diff --git a/tests/cache-files.test.ts b/tests/cache-files.test.ts
new file mode 100644
index 0000000..0dc740a
--- /dev/null
+++ b/tests/cache-files.test.ts
@@ -0,0 +1,100 @@
+import { test, expect, beforeEach, afterEach } from 'bun:test';
+import { calculateFileChecksum, ensureDir, cleanupDir } from '../scripts/cache/files';
+import { mkdir, rm, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { tmpdir } from 'os';
+
+const testDir = join(tmpdir(), 'bun-test-cache');
+
+beforeEach(async () => {
+ await mkdir(testDir, { recursive: true });
+});
+
+afterEach(async () => {
+ await rm(testDir, { recursive: true, force: true });
+});
+
+test('calculateFileChecksum returns consistent hash for same content', async () => {
+ const filePath = join(testDir, 'test.txt');
+ await writeFile(filePath, 'Hello, World!');
+
+ const hash1 = await calculateFileChecksum(filePath);
+ const hash2 = await calculateFileChecksum(filePath);
+
+ expect(hash1).toBe(hash2);
+ expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters
+});
+
+test('calculateFileChecksum returns different hashes for different content', async () => {
+ const filePath1 = join(testDir, 'test1.txt');
+ const filePath2 = join(testDir, 'test2.txt');
+
+ await writeFile(filePath1, 'Hello, World!');
+ await writeFile(filePath2, 'Goodbye, World!');
+
+ const hash1 = await calculateFileChecksum(filePath1);
+ const hash2 = await calculateFileChecksum(filePath2);
+
+ expect(hash1).not.toBe(hash2);
+});
+
+test('calculateFileChecksum handles empty file', async () => {
+ const filePath = join(testDir, 'empty.txt');
+ await writeFile(filePath, '');
+
+ const hash = await calculateFileChecksum(filePath);
+ expect(hash).toHaveLength(64);
+});
+
+test('calculateFileChecksum handles larger file', async () => {
+ const filePath = join(testDir, 'large.txt');
+ const content = 'x'.repeat(11 * 1024 * 1024); // ~11MB (over 10MB threshold)
+ await writeFile(filePath, content);
+
+ const hash = await calculateFileChecksum(filePath);
+ expect(hash).toHaveLength(64);
+});
+
+test('ensureDir creates directory if it does not exist', async () => {
+ const newDir = join(testDir, 'new-directory');
+
+ await ensureDir(newDir);
+
+ // Check if directory exists by trying to create a file in it
+ await writeFile(join(newDir, 'test.txt'), 'test');
+ expect(true).toBe(true); // If we get here, directory exists
+});
+
+test('ensureDir does not error if directory already exists', async () => {
+ await ensureDir(testDir);
+ await ensureDir(testDir); // Should not throw
+
+ expect(true).toBe(true);
+});
+
+test('cleanupDir removes directory and all contents', async () => {
+ const subDir = join(testDir, 'subdir');
+ await mkdir(subDir);
+ await writeFile(join(subDir, 'file.txt'), 'content');
+ await writeFile(join(testDir, 'file2.txt'), 'content2');
+
+ await cleanupDir(testDir);
+
+ // Directory should be gone
+ let exists = true;
+ try {
+ await writeFile(join(testDir, 'test.txt'), 'test');
+ } catch {
+ exists = false;
+ }
+ expect(exists).toBe(false);
+});
+
+test('cleanupDir handles non-existent directory', async () => {
+ const nonExistent = join(testDir, 'does-not-exist');
+
+ // Should not throw
+ await cleanupDir(nonExistent);
+
+ expect(true).toBe(true);
+});
diff --git a/tests/cache-format.test.ts b/tests/cache-format.test.ts
new file mode 100644
index 0000000..33eb89b
--- /dev/null
+++ b/tests/cache-format.test.ts
@@ -0,0 +1,27 @@
+import { test, expect } from 'bun:test';
+
+// formatBytes is an internal function in cache.ts
+// This test verifies the expected behavior
+function formatBytes(bytes: number): string {
+ return (bytes / (1024 * 1024)).toFixed(2);
+}
+
+test('formatBytes converts bytes to MB', () => {
+ expect(formatBytes(1024 * 1024)).toBe('1.00');
+ expect(formatBytes(2 * 1024 * 1024)).toBe('2.00');
+ expect(formatBytes(10.5 * 1024 * 1024)).toBe('10.50');
+});
+
+test('formatBytes rounds to 2 decimal places', () => {
+ expect(formatBytes(1024 * 1024 + 1)).toBe('1.00');
+ expect(formatBytes(1.234 * 1024 * 1024)).toBe('1.23');
+});
+
+test('formatBytes handles zero', () => {
+ expect(formatBytes(0)).toBe('0.00');
+});
+
+test('formatBytes handles small values', () => {
+ expect(formatBytes(512 * 1024)).toBe('0.50');
+ expect(formatBytes(1024)).toBe('0.00');
+});
diff --git a/tests/cache-lock.test.ts b/tests/cache-lock.test.ts
new file mode 100644
index 0000000..c67ba01
--- /dev/null
+++ b/tests/cache-lock.test.ts
@@ -0,0 +1,141 @@
+import { test, expect } from 'bun:test';
+import { generateInstanceId } from '../scripts/cache/lock';
+import type { CacheLock } from '../scripts/cache/utils';
+
+test('generateInstanceId returns non-empty string', () => {
+ const id = generateInstanceId();
+ expect(typeof id).toBe('string');
+ expect(id.length).toBeGreaterThan(0);
+});
+
+test('generateInstanceId includes timestamp', () => {
+ const before = Date.now();
+ const id = generateInstanceId();
+ const after = Date.now();
+
+ const timestampPart = id.split('-')[0];
+ const timestamp = parseInt(timestampPart, 10);
+
+ expect(timestamp).toBeGreaterThanOrEqual(before);
+ expect(timestamp).toBeLessThanOrEqual(after);
+});
+
+test('generateInstanceId includes random component', () => {
+ const id1 = generateInstanceId();
+ const id2 = generateInstanceId();
+
+ expect(id1).not.toBe(id2);
+});
+
+test('generateInstanceId format is timestamp-randomstring', () => {
+ const id = generateInstanceId();
+ const parts = id.split('-');
+ expect(parts.length).toBe(2);
+
+ const [timestamp, random] = parts;
+ expect(timestamp).toMatch(/^\d+$/);
+ expect(random).toMatch(/^[a-z0-9]+$/);
+ expect(random.length).toBeGreaterThan(0);
+ expect(random.length).toBeLessThan(10);
+});
+
+// Helper function to test isLockStale logic (since it's private)
+function isLockStale(lock: CacheLock, currentHostname: string): boolean {
+ const lockAge = Date.now() - lock.timestamp;
+ const timeSinceRenewal = lock.renewedAt ? Date.now() - lock.renewedAt : lockAge;
+
+ // Check 1: Timestamp-based staleness (30 minutes)
+ if (timeSinceRenewal > 30 * 60 * 1000) {
+ return true;
+ }
+
+ // Check 2: Process-based staleness (only on same machine)
+ if (lock.hostname === currentHostname) {
+ // For testing, assume process doesn't exist if pid is -1
+ if (lock.pid === -1) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+test('isLockStale returns true for old lock (over 30 minutes)', () => {
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: Date.now() - 31 * 60 * 1000, // 31 minutes ago
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ pid: 12345,
+ hostname: 'test-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(true);
+});
+
+test('isLockStale returns false for recent lock (under 30 minutes)', () => {
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: Date.now() - 29 * 60 * 1000, // 29 minutes ago
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ pid: 12345,
+ hostname: 'test-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(false);
+});
+
+test('isLockStale respects renewedAt timestamp', () => {
+ const now = Date.now();
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: now - 40 * 60 * 1000, // 40 minutes ago
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ renewedAt: now - 10 * 60 * 1000, // Renewed 10 minutes ago
+ pid: 12345,
+ hostname: 'test-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(false);
+});
+
+test('isLockStale returns true when lock on same host but process dead', () => {
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ pid: -1, // Simulate dead process
+ hostname: 'test-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(true);
+});
+
+test('isLockStale returns false when lock on different host (even if old process)', () => {
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: Date.now() - 10 * 60 * 1000,
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ pid: -1,
+ hostname: 'different-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(false);
+});
+
+test('isLockStale handles missing renewedAt', () => {
+ const lock: CacheLock = {
+ locked: true,
+ timestamp: Date.now() - 35 * 60 * 1000, // 35 minutes ago
+ instance: 'test-instance',
+ ttl: 30 * 60 * 1000,
+ pid: 12345,
+ hostname: 'test-host'
+ };
+
+ expect(isLockStale(lock, 'test-host')).toBe(true);
+});
diff --git a/tests/cache-manifest.test.ts b/tests/cache-manifest.test.ts
new file mode 100644
index 0000000..d54cd8b
--- /dev/null
+++ b/tests/cache-manifest.test.ts
@@ -0,0 +1,76 @@
+import { test, expect } from 'bun:test';
+import { findCacheByKey, findCacheByPrefix } from '../scripts/cache/manifest';
+import type { CacheManifest } from '../scripts/cache/utils';
+
+test('findCacheByKey returns null when cache not found', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: [
+ { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 },
+ { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 }
+ ]
+ };
+
+ expect(findCacheByKey(manifest, 'cache3.tzst')).toBeNull();
+});
+
+test('findCacheByKey returns matching cache entry', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: [
+ { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 },
+ { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 }
+ ]
+ };
+
+ const result = findCacheByKey(manifest, 'cache1.tzst');
+ expect(result).not.toBeNull();
+ expect(result?.key).toBe('cache1.tzst');
+ expect(result?.hash).toBe('abc123');
+});
+
+test('findCacheByPrefix returns null when no caches match prefix', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: [
+ { key: 'cache1.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 },
+ { key: 'cache2.tzst', hash: 'def456', timestamp: 2000, lastAccessed: 2000 }
+ ]
+ };
+
+ expect(findCacheByPrefix(manifest, 'other-')).toBeNull();
+});
+
+test('findCacheByPrefix returns most recently created cache', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: [
+ { key: 'extensions-abc123.tzst', hash: 'abc123', timestamp: 1000, lastAccessed: 1000 },
+ { key: 'extensions-def456.tzst', hash: 'def456', timestamp: 3000, lastAccessed: 3000 },
+ { key: 'extensions-ghi789.tzst', hash: 'ghi789', timestamp: 2000, lastAccessed: 2000 }
+ ]
+ };
+
+ const result = findCacheByPrefix(manifest, 'extensions-');
+ expect(result).not.toBeNull();
+ expect(result?.key).toBe('extensions-def456.tzst');
+ expect(result?.timestamp).toBe(3000);
+});
+
+test('findCacheByPrefix handles empty caches array', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: []
+ };
+
+ expect(findCacheByPrefix(manifest, 'extensions-')).toBeNull();
+});
+
+test('findCacheByKey handles empty caches array', () => {
+ const manifest: CacheManifest = {
+ version: 1,
+ caches: []
+ };
+
+ expect(findCacheByKey(manifest, 'cache1.tzst')).toBeNull();
+});
diff --git a/tests/cache-metadata.test.ts b/tests/cache-metadata.test.ts
new file mode 100644
index 0000000..cf725c6
--- /dev/null
+++ b/tests/cache-metadata.test.ts
@@ -0,0 +1,21 @@
+import { test, expect } from 'bun:test';
+
+// The getMetadataKey function is internal to metadata.ts
+// This test verifies the expected behavior/format
+function getMetadataKey(cacheKey: string): string {
+ return `${cacheKey}.meta.json`;
+}
+
+test('getMetadataKey appends .meta.json to cache key', () => {
+ expect(getMetadataKey('cache.tzst')).toBe('cache.tzst.meta.json');
+ expect(getMetadataKey('extensions-abc123.tzst')).toBe('extensions-abc123.tzst.meta.json');
+ expect(getMetadataKey('my-cache.tar.zst')).toBe('my-cache.tar.zst.meta.json');
+});
+
+test('getMetadataKey handles keys with path', () => {
+ expect(getMetadataKey('path/to/cache.tzst')).toBe('path/to/cache.tzst.meta.json');
+});
+
+test('getMetadataKey handles empty string', () => {
+ expect(getMetadataKey('')).toBe('.meta.json');
+});
diff --git a/tests/cache-utils.test.ts b/tests/cache-utils.test.ts
new file mode 100644
index 0000000..cd3beff
--- /dev/null
+++ b/tests/cache-utils.test.ts
@@ -0,0 +1,23 @@
+import { test, expect } from 'bun:test';
+import { generateCacheKey, CACHE_KEY_PREFIX } from '../scripts/cache/utils';
+
+test('generateCacheKey returns key with correct prefix', async () => {
+ const key = await generateCacheKey();
+
+ expect(key).toStartWith(CACHE_KEY_PREFIX);
+ expect(key).toEndWith('.tzst');
+});
+
+test('generateCacheKey produces consistent hash for same content', async () => {
+ const key1 = await generateCacheKey();
+ const key2 = await generateCacheKey();
+
+ expect(key1).toBe(key2);
+});
+
+test('generateCacheKey produces 64-character hash', async () => {
+ const key = await generateCacheKey();
+ const hashPart = key.replace(CACHE_KEY_PREFIX, '').replace('.tzst', '');
+
+ expect(hashPart).toHaveLength(64);
+});
diff --git a/tests/debounce.test.ts b/tests/debounce.test.ts
new file mode 100644
index 0000000..da029a1
--- /dev/null
+++ b/tests/debounce.test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from 'bun:test';
+import { debounce } from '../src/lib/search/debounce';
+
+test('debounce delays function execution', async () => {
+ const results: number[] = [];
+ const debouncedFn = debounce((value: number) => results.push(value), 100);
+
+ debouncedFn(1);
+ debouncedFn(2);
+ debouncedFn(3);
+
+ expect(results.length).toBe(0);
+
+ await Bun.sleep(150);
+ expect(results.length).toBe(1);
+ expect(results[0]).toBe(3);
+});
+
+test('debounce resets timer on repeated calls', async () => {
+ const results: number[] = [];
+ const debouncedFn = debounce((value: number) => results.push(value), 100);
+
+ debouncedFn(1);
+ await Bun.sleep(50);
+ debouncedFn(2);
+ await Bun.sleep(50);
+ debouncedFn(3);
+ await Bun.sleep(50);
+
+ expect(results.length).toBe(0);
+
+ await Bun.sleep(150);
+ expect(results.length).toBe(1);
+ expect(results[0]).toBe(3);
+});
+
+test('debounce allows multiple executions over time', async () => {
+ const results: number[] = [];
+ const debouncedFn = debounce((value: number) => results.push(value), 50);
+
+ debouncedFn(1);
+ await Bun.sleep(100);
+
+ debouncedFn(2);
+ await Bun.sleep(100);
+
+ expect(results.length).toBe(2);
+ expect(results).toEqual([1, 2]);
+});
+
+test('debounce passes arguments correctly', async () => {
+ const results: [string, number][] = [];
+ const debouncedFn = debounce((text: string, num: number) => results.push([text, num]), 50);
+
+ debouncedFn('hello', 42);
+ await Bun.sleep(100);
+
+ expect(results.length).toBe(1);
+ expect(results[0]).toEqual(['hello', 42]);
+});
diff --git a/tests/logger.test.ts b/tests/logger.test.ts
new file mode 100644
index 0000000..e19f466
--- /dev/null
+++ b/tests/logger.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from 'bun:test';
+
+// Re-implement the helper functions for testing (since they're private in the module)
+function formatTransferStats(bytes: number, elapsedSeconds: number): string {
+ const sizeMB = (bytes / (1024 * 1024)).toFixed(2);
+ const speedMBps = (bytes / (1024 * 1024) / elapsedSeconds).toFixed(2);
+ return `${sizeMB} MB (${speedMBps} MB/s)`;
+}
+
+test('formatTransferStats formats bytes correctly', () => {
+ // 10 MB over 1 second
+ expect(formatTransferStats(10 * 1024 * 1024, 1)).toBe('10.00 MB (10.00 MB/s)');
+
+ // 5.5 MB over 2 seconds (2.75 MB/s)
+ expect(formatTransferStats(5.5 * 1024 * 1024, 2)).toBe('5.50 MB (2.75 MB/s)');
+
+ // 1 KB (0.00 MB)
+ expect(formatTransferStats(1024, 1)).toBe('0.00 MB (0.00 MB/s)');
+});
+
+test('formatTransferStats handles zero elapsed time', () => {
+ // Should handle gracefully (Infinity would be wrong)
+ const bytes = 1024 * 1024; // 1 MB
+ const result = formatTransferStats(bytes, 0);
+ expect(result).toContain('1.00 MB');
+});
diff --git a/tests/search-utils.test.ts b/tests/search-utils.test.ts
new file mode 100644
index 0000000..18d3b45
--- /dev/null
+++ b/tests/search-utils.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from 'bun:test';
+import { findSourceByFormattedName, formatSourceName } from '../src/lib/search/utils';
+
+test('formatSourceName converts to lowercase and replaces spaces with dots', () => {
+ expect(formatSourceName('Example Source')).toBe('example.source');
+ expect(formatSourceName('Multiple Spaces')).toBe('multiple.spaces');
+ expect(formatSourceName('ALREADY LOWERCASE')).toBe('already.lowercase');
+ expect(formatSourceName('Mixed Case')).toBe('mixed.case');
+});
+
+test('findSourceByFormattedName returns "all" for "all"', () => {
+ expect(findSourceByFormattedName('all', ['Source A', 'Source B'])).toBe('all');
+});
+
+test('findSourceByFormattedName finds matching source', () => {
+ const sources = ['Example Source', 'Another Source', 'Test'];
+ expect(findSourceByFormattedName('example.source', sources)).toBe('Example Source');
+ expect(findSourceByFormattedName('another.source', sources)).toBe('Another Source');
+});
+
+test('findSourceByFormattedName returns "all" when no match found', () => {
+ const sources = ['Example Source', 'Another Source'];
+ expect(findSourceByFormattedName('non.existent', sources)).toBe('all');
+});