fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
/**
|
|
* Tier-B · upload (B1) — core upload happy paths.
|
|
*
|
|
* Exercises the two ingest entry points the UI drives from
|
|
* `frontend/src/upload/Upload.tsx`:
|
|
*
|
|
* - POST /api/upload (multipart file upload → Celery `ingest`)
|
|
* - POST /api/remote (form w/ JSON `data` → Celery `ingest_remote`
|
|
* for source=url/github/crawler/reddit/s3/
|
|
* connectors)
|
|
*
|
|
* Plus /api/task_status polling. Each state-changing call is followed by a
|
|
* DB assertion on the `sources` table.
|
|
*
|
|
* URL ingestor choice: we point the `source=url` variant at
|
|
* `http://127.0.0.1:7099/api/config` — a stable always-200 endpoint on the
|
|
* e2e Flask — to avoid external network dependencies. The ingest_remote
|
|
* task invocation + /api/task_status round-trip is what we verify; the
|
|
* actual crawl result is a black box depending on the crawler's user-agent
|
|
* handling, which is out of scope for B1.
|
|
*
|
|
* Known-issue note (not fixed by this spec):
|
|
* After a successful upload, the `sources.id` minted by
|
|
* `SourcesRepository.create` is NOT the same UUID as the faiss index
|
|
* directory the worker wrote (`indexes/<worker-uuid>/`). Chunks lookups
|
|
* keyed on sources.id therefore 500 — this is why chunks.spec.ts
|
|
* explicitly seeds a PG row with the faiss directory's UUID rather than
|
|
* relying on the natural upload flow. We assert the `sources` row exists
|
|
* here and leave the id-mismatch to B3's harness.
|
|
*/
|
|
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import * as playwright from '@playwright/test';
|
|
const { expect, test } = playwright;
|
|
|
|
import { newUserContext } from '../../helpers/auth.js';
|
|
import { pg } from '../../helpers/db.js';
|
|
import { resetDb } from '../../helpers/reset.js';
|
|
import { authedRequest } from '../../helpers/api.js';
|
|
import {
|
|
multipartContext,
|
|
postRemote,
|
|
postUpload,
|
|
waitForTask,
|
|
} from '../../helpers/uploads.js';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURES_DIR = resolve(HERE, '..', '..', 'fixtures', 'docs');
|
|
const SMALL_PDF = resolve(FIXTURES_DIR, 'small.pdf');
|
|
const CORRUPT_PDF = resolve(FIXTURES_DIR, 'corrupt.pdf');
|
|
const NOTES_TXT = resolve(FIXTURES_DIR, 'notes.txt');
|
|
|
|
interface SourceSummary {
|
|
id: string;
|
|
user_id: string;
|
|
name: string;
|
|
type: string | null;
|
|
file_path: string | null;
|
|
}
|
|
|
|
async function getSourcesForUser(userId: string): Promise<SourceSummary[]> {
|
|
const { rows } = await pg.query<SourceSummary>(
|
|
`SELECT id::text AS id, user_id, name, type, file_path
|
|
FROM sources WHERE user_id = $1 ORDER BY created_at DESC`,
|
|
[userId],
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
test.describe('tier-b · upload', () => {
|
|
test.beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
test('POST /api/upload small.pdf → task runs and sources row exists', async ({ browser }) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const multi = await multipartContext(token);
|
|
const apiJson = await authedRequest(playwright, token);
|
|
try {
|
|
const taskId = await postUpload(multi, SMALL_PDF, {
|
|
user: sub,
|
|
name: 'small-pdf-upload',
|
|
});
|
|
expect(taskId).toMatch(/^[0-9a-f-]{36}$/i);
|
|
|
|
const final = await waitForTask(apiJson, taskId);
|
|
expect(
|
|
final.status,
|
|
`ingest terminal state unexpected: ${JSON.stringify(final)}`,
|
|
).toBe('SUCCESS');
|
|
|
|
// The worker's `upload_index` call triggers `SourcesRepository.create`
|
|
// via /api/upload_index (application/api/internal/routes.py:139). The
|
|
// row is owned by the JWT `sub`, and `file_path` mirrors the storage
|
|
// path for the uploaded artifact.
|
|
const rows = await getSourcesForUser(sub);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe('small-pdf-upload');
|
|
expect(rows[0].type).toBe('local');
|
|
expect(rows[0].file_path).toContain('small-pdf-upload');
|
|
} finally {
|
|
await multi.dispose();
|
|
await apiJson.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/remote url ingestor accepts a valid URL and returns a task_id', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const multi = await multipartContext(token);
|
|
const apiJson = await authedRequest(playwright, token);
|
|
try {
|
|
const taskId = await postRemote(multi, {
|
|
user: sub,
|
|
name: 'url-ingest',
|
|
source: 'url',
|
|
// Pointing at the e2e Flask's own /api/config — always 200, served
|
|
// by this same process, no external dependencies.
|
|
data: { url: 'http://127.0.0.1:7099/api/config' },
|
|
});
|
|
expect(taskId).toMatch(/^[0-9a-f-]{36}$/i);
|
|
|
|
// We do not require SUCCESS — the remote worker's URL loader path
|
|
// may fail on the JSON response body (it's not HTML), but the
|
|
// endpoint contract (task_id returned, task_status reachable) is
|
|
// what we're asserting here. Drain to a terminal state either way
|
|
// so we don't leak an in-flight task into the next test.
|
|
const final = await waitForTask(apiJson, taskId, 30_000);
|
|
expect(['SUCCESS', 'FAILURE']).toContain(final.status);
|
|
} finally {
|
|
await multi.dispose();
|
|
await apiJson.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/upload corrupt.pdf → task completes (may SUCCESS with empty docs or FAILURE at parse)', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const multi = await multipartContext(token);
|
|
const apiJson = await authedRequest(playwright, token);
|
|
try {
|
|
const taskId = await postUpload(multi, CORRUPT_PDF, {
|
|
user: sub,
|
|
name: 'corrupt-upload',
|
|
mimeType: 'application/pdf',
|
|
});
|
|
|
|
const final = await waitForTask(apiJson, taskId, 45_000);
|
|
// The docling PDF pipeline is lenient — a stripped PDF body like our
|
|
// `corrupt.pdf` fixture either parses to an empty doc (SUCCESS with
|
|
// zero chunks) or raises mid-pipeline (FAILURE). Both reflect the
|
|
// API contract truthfully; we only require the task completes and
|
|
// /api/task_status surfaces a stable terminal envelope.
|
|
expect(['SUCCESS', 'FAILURE']).toContain(final.status);
|
|
if (final.status === 'FAILURE') {
|
|
// FAILURE meta is stringified — not a progress dict.
|
|
expect(typeof final.result).toBe('string');
|
|
}
|
|
} finally {
|
|
await multi.dispose();
|
|
await apiJson.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/upload notes.txt with unicode-bearing job name → sources.name stored verbatim', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const multi = await multipartContext(token);
|
|
const apiJson = await authedRequest(playwright, token);
|
|
try {
|
|
// Unicode + spaces in the *job name* (which becomes sources.name).
|
|
// The backend only applies `safe_filename` to the filesystem dir
|
|
// derivatives — the stored DB name is the raw input.
|
|
const jobName = 'notes-уникод спейсы';
|
|
const taskId = await postUpload(multi, NOTES_TXT, {
|
|
user: sub,
|
|
name: jobName,
|
|
mimeType: 'text/plain',
|
|
filename: 'notes-file.txt',
|
|
});
|
|
|
|
const final = await waitForTask(apiJson, taskId);
|
|
expect(final.status).toBe('SUCCESS');
|
|
|
|
const rows = await getSourcesForUser(sub);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe(jobName);
|
|
} finally {
|
|
await multi.dispose();
|
|
await apiJson.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/upload with no file field → 400 and no sources row', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const multi = await multipartContext(token);
|
|
try {
|
|
// Omit `file` entirely — backend validates via `request.files.getlist`
|
|
// and `check_required_fields` in sources/upload.py:62-71.
|
|
const res = await multi.post('/api/upload', {
|
|
multipart: {
|
|
user: sub,
|
|
name: 'missing-file-job',
|
|
},
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
|
|
const rows = await getSourcesForUser(sub);
|
|
expect(rows).toHaveLength(0);
|
|
} finally {
|
|
await multi.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|