Skip to content

Commit a7834a8

Browse files
pawelniewieclaude
andcommitted
Add batch import integration test and local test runner script
Tests the full ImportSession API flow (create session, submit manifest, upload via presigned URLs, trigger processing, poll completion) and the ImportSessionManager end-to-end pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ba9707 commit a7834a8

File tree

2 files changed

+211
-0
lines changed

2 files changed

+211
-0
lines changed

scripts/integration-test-local.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
6+
CLOUD_DIR="${FUNDAMENTO_CLOUD_DIR:-$PROJECT_DIR/../fundamento-cloud}"
7+
8+
BASE_URL="${FUNDAMENTO_TEST_URL:-http://localhost:3000}"
9+
EMAIL="${FUNDAMENTO_TEST_EMAIL:-sarah@brightpath.example.com}"
10+
11+
echo "Checking if Fundamento is running at $BASE_URL..."
12+
if ! curl -sf "$BASE_URL" > /dev/null 2>&1; then
13+
echo "ERROR: Fundamento is not running at $BASE_URL"
14+
echo "Start it with: cd $CLOUD_DIR && bin/dev"
15+
exit 1
16+
fi
17+
18+
echo "Creating API token for $EMAIL..."
19+
API_KEY=$(cd "$CLOUD_DIR" && bin/rails runner "
20+
user = User.find_by!(email: '$EMAIL')
21+
om = user.organization_memberships.first
22+
token = om.api_tokens.find_or_create_by!(title: 'CLI Integration Test') do |t|
23+
t.organization = om.organization
24+
end
25+
print token.encrypted_token
26+
" 2>/dev/null)
27+
28+
if [ -z "$API_KEY" ]; then
29+
echo "ERROR: Failed to create API token"
30+
echo "Make sure seeds have been run: cd $CLOUD_DIR && bin/rails db:seed"
31+
exit 1
32+
fi
33+
34+
ENV_FILE="$PROJECT_DIR/.env.test"
35+
cat > "$ENV_FILE" <<EOF
36+
FUNDAMENTO_TEST_URL=$BASE_URL
37+
FUNDAMENTO_TEST_API_KEY=$API_KEY
38+
EOF
39+
40+
echo "API token saved to .env.test"
41+
echo ""
42+
echo "Run tests with: npm run test:integration"
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { describe, it, before, after } from "node:test";
2+
import assert from "node:assert";
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import os from "node:os";
6+
import crypto from "crypto";
7+
import { createTestClient, uniqueName } from "./helpers.js";
8+
import { ImportSessionManager } from "../src/import_session.js";
9+
10+
function md5Base64(content) {
11+
return crypto.createHash("md5").update(content).digest("base64");
12+
}
13+
14+
const FILE_CONTENTS = {
15+
"README.md": "---\ntitle: Project README\n---\n\n# README\n\nTop-level document.",
16+
"Guide/getting-started.md": "# Getting Started\n\nFirst steps.",
17+
"Guide/advanced.md": "# Advanced Usage\n\nDeep dive.",
18+
"assets/logo.png": "fake-png-data"
19+
};
20+
21+
function buildManifestEntries() {
22+
return Object.entries(FILE_CONTENTS).map(([relativePath, content]) => {
23+
const ext = path.extname(relativePath).slice(1);
24+
const isAttachment = ["png", "jpg", "jpeg", "gif", "webp", "svg", "pdf"].includes(ext);
25+
return {
26+
relative_path: relativePath,
27+
checksum: md5Base64(content),
28+
file_size: Buffer.byteLength(content),
29+
format: isAttachment ? "image" : "markdown",
30+
file_type: isAttachment ? "attachment" : "document"
31+
};
32+
});
33+
}
34+
35+
describe("batch import (ImportSessionManager)", () => {
36+
let client;
37+
let testSpaceId;
38+
let tmpDir;
39+
40+
before(async () => {
41+
client = createTestClient();
42+
43+
const space = await client.createSpace({
44+
name: uniqueName("batch-import-tests"),
45+
accessMode: "private"
46+
});
47+
testSpaceId = space.id;
48+
49+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "funcli-batch-import-"));
50+
51+
for (const [relativePath, content] of Object.entries(FILE_CONTENTS)) {
52+
const fullPath = path.join(tmpDir, relativePath);
53+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
54+
fs.writeFileSync(fullPath, content);
55+
}
56+
});
57+
58+
after(() => {
59+
if (tmpDir) {
60+
fs.rmSync(tmpDir, { recursive: true, force: true });
61+
}
62+
});
63+
64+
describe("API flow", () => {
65+
let sessionId;
66+
let fileEntries;
67+
68+
it("creates an import session", async () => {
69+
const session = await client.createImportSession({
70+
spaceId: testSpaceId,
71+
sourceFormat: "generic"
72+
});
73+
74+
sessionId = session.id;
75+
assert(sessionId, "session should have an id");
76+
assert.strictEqual(session.status, "pending");
77+
});
78+
79+
it("submits a manifest and receives presigned upload URLs", async () => {
80+
fileEntries = await client.submitManifest(sessionId, buildManifestEntries());
81+
82+
assert.strictEqual(fileEntries.length, 4, "should return 4 file entries");
83+
for (const entry of fileEntries) {
84+
assert(entry.id, "each entry should have an id");
85+
assert(entry.direct_upload_url, "each entry should have a direct_upload_url");
86+
assert(entry.relative_path, "each entry should have a relative_path");
87+
}
88+
});
89+
90+
it("uploads files to presigned URLs and marks them uploaded", async () => {
91+
const toUpload = fileEntries.filter(e => e.direct_upload_url);
92+
93+
for (const entry of toUpload) {
94+
const content = FILE_CONTENTS[entry.relative_path];
95+
assert(content !== undefined, `should have content for ${entry.relative_path}`);
96+
97+
const headers = { ...entry.direct_upload_headers };
98+
if (!headers["Content-Type"]) {
99+
headers["Content-Type"] = entry.content_type || "application/octet-stream";
100+
}
101+
102+
const res = await fetch(entry.direct_upload_url, {
103+
method: "PUT",
104+
headers,
105+
body: content
106+
});
107+
assert(res.ok, `upload should succeed for ${entry.relative_path}, got ${res.status}: ${await res.text()}`);
108+
109+
await client.markFileUploaded(sessionId, entry.id);
110+
}
111+
});
112+
113+
it("triggers processing and completes", async () => {
114+
await client.triggerProcessing(sessionId);
115+
116+
// Poll until processing finishes (max 30 seconds)
117+
let session;
118+
for (let i = 0; i < 15; i++) {
119+
await new Promise(r => setTimeout(r, 2000));
120+
session = await client.getImportSession(sessionId);
121+
if (["completed", "partial", "failed"].includes(session.status)) break;
122+
}
123+
124+
assert(["completed", "partial"].includes(session.status),
125+
`session should be completed or partial, got: ${session.status}`);
126+
assert(session.processed_files > 0, "should have processed files");
127+
});
128+
129+
it("session status includes file details", async () => {
130+
const session = await client.getImportSession(sessionId);
131+
assert(session.files, "session should include files");
132+
assert(session.files.length > 0, "should have file entries");
133+
134+
const completedFiles = session.files.filter(f => f.status === "completed");
135+
assert(completedFiles.length > 0, "should have completed files");
136+
});
137+
});
138+
139+
describe("ImportSessionManager.start()", () => {
140+
it("runs the full import pipeline end-to-end", async () => {
141+
const manager = new ImportSessionManager(client, { concurrency: 2 });
142+
const sessionFile = path.join(tmpDir, ".test-session.json");
143+
144+
// Suppress stdout progress output during test
145+
const origWrite = process.stdout.write;
146+
const origLog = console.log;
147+
process.stdout.write = () => true;
148+
console.log = () => {};
149+
150+
try {
151+
await manager.start(testSpaceId, tmpDir, { sessionFile });
152+
} finally {
153+
process.stdout.write = origWrite;
154+
console.log = origLog;
155+
}
156+
157+
// Verify session file was created
158+
assert(fs.existsSync(sessionFile), "session file should be created");
159+
const sessionData = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
160+
assert(sessionData.session_id, "session file should contain session_id");
161+
162+
// Verify completion via API
163+
const session = await client.getImportSession(sessionData.session_id);
164+
assert(["completed", "partial"].includes(session.status),
165+
`session should be completed or partial, got: ${session.status}`);
166+
assert.strictEqual(session.total_files, 4, "should have 4 total files (3 docs + 1 attachment)");
167+
});
168+
});
169+
});

0 commit comments

Comments
 (0)