screentinker/server/test/content-multi-upload.test.js
screentinker 5c6d508032
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
feat(content): multi-file upload (#222)
Uploading N files fired N sequential XHRs (one POST per file). Select-many now
goes up in a single request.

- Server POST /api/content: upload.array-style `files` field (up to 20) via
  upload.fields, looping ingestUploadedFile per file. Keeps the legacy single
  `file` field so older clients / API callers are unaffected. Response shape is
  backward-compatible: a single file returns the content object (what every
  existing caller reads), a batch returns the array.
- api.uploadContent: accepts a File, FileList, or array; appends all under
  `files`; aggregate upload progress; resolves to object (single) or array
  (batch).
- content-library handleFiles: one batched request with aggregate progress and
  a "N files uploaded" toast instead of a per-file loop.
- en/es i18n for the count-based progress/toast strings.

checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when
already at/over the limit), same as before; per-file aggregate sizing was a
listed "consideration", not required, and is out of scope here.

Test: content-multi-upload.test.js drives the real router+multer over HTTP —
3-file batch creates 3 rows and returns an array, legacy single `file` returns
an object, single `files` returns an object, empty -> 400. Suite 545/545.

Closes #212

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:37 -05:00

108 lines
4.5 KiB
JavaScript

'use strict';
// #212 multi-file upload. POST /api/content now accepts N files under the `files` field
// (one request instead of N), while still accepting the legacy single `file` field.
// Exercises the real router + multer over HTTP.
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-multiup-'));
process.env.DATA_DIR = TMP;
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const { db } = require('../db/database');
// A minimal valid PNG (8-byte signature + padding) — enough to pass the mime filter.
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(32)]);
let server, base;
const USER = 'u-multiup';
// Build a multipart/form-data body from field parts. Each file part: {field, filename, data}.
function multipart(fileParts) {
const boundary = '----st' + crypto.randomBytes(8).toString('hex');
const chunks = [];
for (const f of fileParts) {
chunks.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${f.field}"; filename="${f.filename}"\r\n` +
`Content-Type: image/png\r\n\r\n`));
chunks.push(f.data);
chunks.push(Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return { body: Buffer.concat(chunks), boundary };
}
function post(fileParts) {
const { body, boundary } = multipart(fileParts);
return new Promise((resolve, reject) => {
const req = http.request(base + '/', {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length },
}, (res) => {
let out = '';
res.on('data', (c) => (out += c));
res.on('end', () => resolve({ status: res.statusCode, json: out ? JSON.parse(out) : null }));
});
req.on('error', reject);
req.end(body);
});
}
before(async () => {
// The fresh DATA_DIR has no uploads/content dir (the real app creates it at boot);
// multer's diskStorage would ENOENT without it.
fs.mkdirSync(require('../config').contentDir, { recursive: true });
db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local');
// content.workspace_id -> workspaces.id -> organizations.id are real FKs, so seed the chain.
db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-multiup', 'Org', ?)").run(USER);
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-multiup', 'org-multiup', 'WS')").run();
const app = express();
app.use((req, _res, next) => { req.workspaceId = 'ws-multiup'; req.user = { id: USER, role: 'admin' }; next(); });
app.use('/', require('../routes/content'));
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((r) => server.close(r)));
test('multiple files under `files` create one content row each and return an array', async () => {
const r = await post([
{ field: 'files', filename: 'a.png', data: PNG },
{ field: 'files', filename: 'b.png', data: PNG },
{ field: 'files', filename: 'c.png', data: PNG },
]);
assert.equal(r.status, 201);
assert.ok(Array.isArray(r.json), 'batch upload returns an array');
assert.equal(r.json.length, 3);
const rows = db.prepare("SELECT filename FROM content WHERE workspace_id = 'ws-multiup'").all().map((x) => x.filename);
for (const n of ['a.png', 'b.png', 'c.png']) assert.ok(rows.includes(n), `${n} was ingested`);
});
test('legacy single `file` field still returns a single content object', async () => {
const r = await post([{ field: 'file', filename: 'legacy.png', data: PNG }]);
assert.equal(r.status, 201);
assert.ok(!Array.isArray(r.json), 'single legacy upload returns an object, not an array');
assert.equal(r.json.filename, 'legacy.png');
});
test('a single file under `files` also returns a single object (shape parity)', async () => {
const r = await post([{ field: 'files', filename: 'one.png', data: PNG }]);
assert.equal(r.status, 201);
assert.ok(!Array.isArray(r.json));
assert.equal(r.json.filename, 'one.png');
});
test('no files -> 400', async () => {
const r = await post([]);
assert.equal(r.status, 400);
});