als-send-file
Version:
file serving with advanced options for caching, headers, and error handling, compatible with Express middleware.
193 lines (164 loc) • 8.59 kB
JavaScript
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert');
const http = require('http');
const port = 1234;
const baseUrl = `http://localhost:${port}/`;
const fileHandler = require('../lib/file-handler.js');
const httpErrHandler = (res, code, msg) => { res.writeHead(code); res.end(msg); };
const { join } = require('path');
const root = join(__dirname, '..');
const { writeFileSync, unlinkSync, existsSync } = require('node:fs');
const server = http.createServer(async (req, res) => {
const { search, pathname } = new URL(req.url, 'http://some.com');
const options = {};
search.replace('?', '').split(';').forEach(val => {
let [k, v] = val.split('=');
if (v === 'true') v = true
if (k) options[k] = v;
});
if (pathname === '/early-end') setTimeout(() => { res.end() }, 1);
await fileHandler(req, res, join(root, pathname), options, httpErrHandler);
});
async function request(url, headers = {}, method = 'GET') {
const response = await fetch(`${baseUrl}${url}`, { method, headers });
const text = await response.text();
return [response.status, text, response.headers, response];
}
describe('fileHandler Tests', () => {
before(() => {
server.listen(port, () => { console.log(`Server running on http://localhost:${port}`); });
});
after((t,done) => {
server.close(done);
});
describe('try catch for stat(filepath)', () => {
it('should return 404 if file not found', async () => {
const [status, text] = await request('nonexistent-file.txt');
assert.strictEqual(status, 404);
assert.strictEqual(text, 'File nonexistent-file.txt not found');
});
})
describe('etag and cache control tests', () => {
it('should handle ETag and return 304 if not modified', async () => {
const testFilePath = 'test-file-etag.txt';
writeFileSync(testFilePath, 'Hello from test-file-etag.txt');
const [status, , headers] = await request(testFilePath + '?etag=true');
const etag = headers.get('etag');
const [statusNotModified] = await request(testFilePath + '?etag=true', { 'If-None-Match': etag });
assert.strictEqual(statusNotModified, 304);
unlinkSync(testFilePath);
});
it('should handle Cache-Control headers', async () => {
const testFilePath = 'test-file-cache.txt';
writeFileSync(testFilePath, 'Hello from test-file-cache.txt');
const [status, , headers] = await request(`${testFilePath}?maxAge=3600;noCache=true;public=public`);
assert.strictEqual(status, 200);
assert.strictEqual(headers.get('cache-control'), 'public, max-age=3600, no-cache');
unlinkSync(testFilePath);
});
})
describe('Content-Disposition tests', () => {
const testFilePath = 'test-file-download.txt';
before(() => { writeFileSync(testFilePath, 'Hello from test-file-download.txt'); })
after(() => { unlinkSync(testFilePath); })
it('should handle Content-Disposition for download', async () => {
const [status, , headers] = await request(`${testFilePath}?download=true`);
assert.strictEqual(status, 200);
assert.strictEqual(headers.get('content-disposition'), `attachment; filename="${testFilePath}"`);
});
it('should handle Content-Disposition for inline', async () => {
const [status, , headers] = await request(`${testFilePath}`);
assert.strictEqual(status, 200);
assert.strictEqual(headers.get('content-disposition'), `inline; filename="${testFilePath}"`);
});
})
describe('charset and mime tests', () => {
const testFilePath = 'test-file.txt', content = 'Hello from test-file.txt';
before(() => { writeFileSync(testFilePath, content); })
after(() => { if(existsSync(testFilePath)) unlinkSync(testFilePath); })
it('should handle charset in Content-Type header', async () => {
const [status, , headers] = await request(`${testFilePath}?charset=utf-8`);
assert(status === 200);
assert(headers.get('content-type') === 'text/plain; charset=utf-8');
});
it('should serve a file successfully', async () => {
const [status, text, headers] = await request(testFilePath);
assert(status === 200);
assert(text.includes(content));
assert(headers.get('content-type') === 'text/plain');
});
it('should serve a file successfully', async () => {
const testFilePath = 'test-file', content = 'Hello from test-file';
writeFileSync(testFilePath, content);
const [status, text, headers] = await request(testFilePath);
assert(status === 200);
assert(text.includes(content));
assert(headers.get('content-type') === 'text/plain');
unlinkSync(testFilePath)
});
it('should handle all Cache-Control directives together', async () => {
const testFilePath = 'test-file-combo-cache.txt';
writeFileSync(testFilePath, 'Hello from test-file-combo-cache.txt');
const [status, , headers] = await request(`${testFilePath}?maxAge=3600;noCache=true;noStore=true;public=public`);
assert.strictEqual(status, 200);
assert.strictEqual(headers.get('cache-control'), 'public, max-age=3600, no-cache, no-store');
unlinkSync(testFilePath);
});
it('should handle all Content-Disposition and charset options', async () => {
const testFilePath = 'test-file-combo-disposition.txt';
writeFileSync(testFilePath, 'Hello from test-file-combo-disposition.txt');
const [status, , headers] = await request(`${testFilePath}?download=true;charset=utf-8`);
assert.strictEqual(status, 200);
assert.strictEqual(headers.get('content-disposition'), `attachment; filename="${testFilePath}"`);
assert.strictEqual(headers.get('content-type'), 'text/plain; charset=utf-8');
unlinkSync(testFilePath);
});
})
describe('sendFile tests', () => {
it('should return 416 for invalid range requests', async () => {
const testFilePath = 'test-file-invalid-range.txt';
writeFileSync(testFilePath, 'Hello from test-file-invalid-range.txt');
const [status, text] = await request(testFilePath, { 'Range': 'bytes=-' });
assert.strictEqual(status, 416);
assert.strictEqual(text, 'Range is not valid');
unlinkSync(testFilePath);
});
it('should handle range requests correctly', async () => {
const testFilePath = 'test-file-range.txt';
writeFileSync(testFilePath, 'Hello from test-file-range.txt');
const [status, text, headers] = await request(testFilePath, { 'Range': 'bytes=0-4' });
assert.strictEqual(status, 206);
assert.strictEqual(text, 'Hello');
assert.strictEqual(headers.get('content-range'), `bytes 0-4/30`);
unlinkSync(testFilePath);
});
})
describe('Errors', () => {
it('should abort connection', async () => {
const testFilePath = 'test-file-abort.txt';
writeFileSync(testFilePath, 'Hello from test-file-abort.txt');
const controller = new AbortController();
const signal = controller.signal;
// Отправка запроса с использованием AbortController
const fetchPromise = fetch(`${baseUrl}${testFilePath}`, { signal })
.then(response => response.text())
.catch(err => {
if (err.name === 'AbortError') return 'aborted';
// throw err;
});
setTimeout(() => { controller.abort() }, 1);
const result = await fetchPromise;
assert.strictEqual(result, 'aborted');
unlinkSync(testFilePath);
});
it('should end response before file is fully sent', async () => {
const testFilePath = 'test-file-early-end.txt';
writeFileSync(testFilePath, 'Hello from test-file-early-end.txt');
// Отправка запроса к специальному маршруту, который завершает res.end раньше
const [status, text] = await request(`early-end`);
assert.strictEqual(status, 200);
assert.strictEqual(text, ''); // Ожидаем, что текст будет пустым, так как res.end вызвано до завершения передачи файла
unlinkSync(testFilePath);
});
});
});