Files
insforge--insforge/backend/tests/unit/localstorageprovider.test.ts
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

225 lines
7.3 KiB
TypeScript

import { LocalStorageProvider } from '../../src/providers/storage/local.provider.ts';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import * as path from 'path';
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return {
...actual,
rm: vi.fn(actual.rm),
unlink: vi.fn(actual.unlink),
};
});
describe('LocalStorageProvider - deleteBucket', () => {
const baseDir = path.join(__dirname, 'test-storage');
let provider: LocalStorageProvider;
beforeEach(async () => {
provider = new LocalStorageProvider(baseDir);
await provider.initialize();
});
afterEach(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('deletes an existing bucket', async () => {
const bucket = 'testBucket';
const bucketPath = path.join(baseDir, bucket);
await fs.mkdir(bucketPath, { recursive: true });
await provider.deleteBucket(bucket);
await expect(fs.access(bucketPath)).rejects.toThrow();
});
it('does not throw if bucket does not exist (ENOENT)', async () => {
await expect(provider.deleteBucket('nonExistentBucket')).resolves.toBeUndefined();
});
it('rethrows unexpected fs errors', async () => {
const bucket = 'testBucket';
const spy = vi.spyOn(fs, 'rm').mockRejectedValue({ code: 'EACCES' } as NodeJS.ErrnoException);
await expect(provider.deleteBucket(bucket)).rejects.toEqual({
code: 'EACCES',
});
spy.mockRestore();
});
it('throws for empty bucket name', async () => {
await expect(provider.deleteBucket('')).rejects.toThrow('Invalid bucket name');
});
it('throws for whitespace-only bucket name', async () => {
await expect(provider.deleteBucket(' ')).rejects.toThrow('Invalid bucket name');
});
it('throws for bucket name with invalid characters', async () => {
await expect(provider.deleteBucket('.')).rejects.toThrow(
'Bucket name contains invalid characters'
);
await expect(provider.deleteBucket('..')).rejects.toThrow(
'Bucket name contains invalid characters'
);
await expect(provider.deleteBucket('foo/bar')).rejects.toThrow(
'Bucket name contains invalid characters'
);
});
});
describe('LocalStorageProvider - putObject etag', () => {
const baseDir = path.join(__dirname, 'test-storage-etag');
let provider: LocalStorageProvider;
beforeEach(async () => {
provider = new LocalStorageProvider(baseDir);
await provider.initialize();
await provider.createBucket('etagBucket');
});
afterEach(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
function makeFile(buffer: Buffer): Express.Multer.File {
return {
buffer,
mimetype: 'application/octet-stream',
originalname: 'x.bin',
size: buffer.length,
fieldname: 'file',
encoding: '7bit',
stream: undefined as never,
destination: '',
filename: '',
path: '',
} as Express.Multer.File;
}
it('returns an md5 etag for stored bytes', async () => {
const file = makeFile(Buffer.from('hello world'));
const { etag } = await provider.putObject('etagBucket', 'key1', file);
// md5('hello world')
expect(etag).toBe('5eb63bbbe01eeed093cb22bb8f5acdc3');
});
it('returns the same etag when the same bytes are written again', async () => {
const a = await provider.putObject('etagBucket', 'k', makeFile(Buffer.from('same')));
const b = await provider.putObject('etagBucket', 'k', makeFile(Buffer.from('same')));
expect(a.etag).toBe(b.etag);
});
it('returns a different etag when bytes change', async () => {
const a = await provider.putObject('etagBucket', 'k', makeFile(Buffer.from('v1')));
const b = await provider.putObject('etagBucket', 'k', makeFile(Buffer.from('v2')));
expect(a.etag).not.toBe(b.etag);
});
});
describe('LocalStorageProvider - deleteObjects', () => {
const baseDir = path.join(__dirname, 'test-storage-delete-objects');
let provider: LocalStorageProvider;
beforeEach(async () => {
provider = new LocalStorageProvider(baseDir);
await provider.initialize();
await provider.createBucket('batchBucket');
});
afterEach(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('deletes multiple files and ignores missing files', async () => {
await fs.mkdir(path.join(baseDir, 'batchBucket', 'folder'), { recursive: true });
await fs.writeFile(path.join(baseDir, 'batchBucket', 'a.txt'), 'a');
await fs.writeFile(path.join(baseDir, 'batchBucket', 'folder', 'b.txt'), 'b');
const result = await provider.deleteObjects('batchBucket', [
'a.txt',
'folder/b.txt',
'missing.txt',
]);
expect(result).toEqual({
deleted: ['a.txt', 'folder/b.txt', 'missing.txt'],
failed: [],
});
await expect(fs.access(path.join(baseDir, 'batchBucket', 'a.txt'))).rejects.toThrow();
await expect(fs.access(path.join(baseDir, 'batchBucket', 'folder', 'b.txt'))).rejects.toThrow();
});
it('reports unexpected unlink failures per key', async () => {
vi.spyOn(fs, 'unlink').mockRejectedValueOnce(
Object.assign(new Error('permission denied'), { code: 'EACCES' })
);
const result = await provider.deleteObjects('batchBucket', ['blocked.txt']);
expect(result).toEqual({
deleted: [],
failed: [{ key: 'blocked.txt', message: 'Failed to delete object' }],
});
});
it('limits concurrent unlink calls when deleting many files', async () => {
let active = 0;
let maxActive = 0;
vi.spyOn(fs, 'unlink').mockImplementation(async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 1));
active -= 1;
});
const keys = Array.from({ length: 30 }, (_, index) => `file-${index}.txt`);
const result = await provider.deleteObjects('batchBucket', keys);
expect(result).toEqual({ deleted: keys, failed: [] });
expect(maxActive).toBeLessThanOrEqual(25);
});
});
describe('LocalStorageProvider - getDownloadStrategy versioning', () => {
const baseDir = path.join(__dirname, 'test-storage-dl');
let provider: LocalStorageProvider;
beforeEach(async () => {
provider = new LocalStorageProvider(baseDir);
await provider.initialize();
process.env.API_BASE_URL = 'https://app.test';
});
afterEach(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
delete process.env.API_BASE_URL;
vi.restoreAllMocks();
});
it('omits ?v= when no version is supplied', async () => {
const s = await provider.getDownloadStrategy('b', 'k.png');
expect(s.url).toBe('https://app.test/api/storage/buckets/b/objects/k.png');
});
it('appends ?v=<version> when a version is supplied', async () => {
const s = await provider.getDownloadStrategy('b', 'k.png', 0, true, 'abc123');
expect(s.url).toBe('https://app.test/api/storage/buckets/b/objects/k.png?v=abc123');
});
it('url-encodes the version stamp', async () => {
const s = await provider.getDownloadStrategy('b', 'k.png', 0, true, 'a b&c');
expect(s.url).toBe('https://app.test/api/storage/buckets/b/objects/k.png?v=a%20b%26c');
});
});