unsloth/studio/backend/tests/test_rag_project_source_upload.py
Michael Han bac04ab577
Add drag and drop sources to the create project dialog (#7441)
* feat(studio): add drag and drop sources to create project

Files dropped on the create-project dialog upload to the new project's
sources as soon as it exists, so a project can start with context instead
of needing a second trip to the Sources tab.

The sidebar and projects page dialogs now reuse NewProjectDialog rather
than each keeping their own copy, and the OCR / caption ingest overrides
move to a shared helper so every upload path sends the same settings.

* fix(studio): harden project source drops

Drops are not filtered by the `accept` attribute the way the picker is, so a
folder or an image would stage and then fail server-side with a confusing
per-file error. Unsupported entries are now refused up front with one message.

Cancel bypassed the dialog's reset, so a discarded name and its staged files
came back on reopen and uploaded into the next project created. Every close
path now goes through one handler.

Long filenames lost their extension in _sanitize_filename and were then
rejected as an unsupported type; the stem is trimmed instead. Adds backend
tests for the project scope, the sanitizer and path stripping.

* fix(studio): address second review pass on source drops

A drop landing on the panel while uploads run was not cancelled, because
pointer-events-none took the panel out of hit testing and nothing else on the
page cancels a file drop. The browser would navigate to the file and kill the
uploads in flight. Drag defaults are now cancelled even while disabled, and the
files are ignored instead.

Name, size and mtime can match for two genuinely different files, so a skipped
duplicate now says so rather than disappearing.

A slow upload could resolve after the dialog unmounted and still navigate,
pulling the user off the page they had moved to. Post-upload work is gated on
the component still being mounted.

* fix(studio): make source drops safe under StrictMode replay

The mount sentinel was only cleared in effect cleanup, so StrictMode's
setup/cleanup/setup replay left it false for good and every create in a dev
build stopped short of closing the dialog or navigating. It is now set on
setup as well.

The pending-sources marker was consumed inside a useState initializer, which
React replays, so the discarded pass ate the flag and the project opened on
Chats. Reading is now a peek and the marker is dropped in an effect.

Identical bytes under two names collapse to one document server-side, which
looked like both files had been added. The upload loop now tracks returned
document ids and says when files were merged.

* fix(studio): guard the route and storage around staged uploads

The sidebar's dialog lives in the root layout and never unmounts on a route
change, so the mount check alone could not stop a slow upload from navigating
the user back to the new project. The route is captured when create is pressed
and compared afterwards, and callers get that answer so the sidebar can still
move a chat while leaving the user where they are.

Reading the vision-pass overrides went straight at localStorage, which throws
outright where storage is blocked. That happened before the upload loop, so a
project was created and every staged source was lost. It now falls back to the
backend defaults, matching loadOptionalBool in the chat runtime store.
2026-07-25 23:54:48 -07:00

81 lines
2.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Project sources upload: the path the create-project dialog drives."""
import os
import pytest
from core.rag import ingestion, store
from routes.rag import _sanitize_filename
from storage import rag_db
def _wait(job_id, timeout = 30.0):
import time
deadline = time.time() + timeout
while time.time() < deadline:
status = ingestion.get_job_status(job_id)
if status and status["status"] in ("completed", "failed"):
return status
time.sleep(0.05)
raise AssertionError("ingestion did not finish in time")
def _ingest(project_id, filename, path):
return ingestion.start_ingestion(
store.project_scope(project_id), None, None, filename, path, project_id = project_id
)
def test_project_document_persists_under_its_scope(rag_home, stub_embeddings, tmp_path):
path = tmp_path / "notes.txt"
path.write_text("alpha bravo charlie " * 50, encoding = "utf-8")
_, job_id = _ingest("P1", "notes.txt", str(path))
assert _wait(job_id)["status"] == "completed"
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.project_scope("P1"))
assert [d["filename"] for d in docs] == ["notes.txt"]
# Scoped: a sibling project cannot see it.
assert store.list_documents(conn, store.project_scope("P2")) == []
assert store.search_lexical(conn, store.project_scope("P1"), "bravo", 5)
finally:
conn.close()
@pytest.mark.parametrize(
"raw",
[
"x" * 300 + ".txt",
"y" * 512 + ".PDF",
"../" * 80 + "deep.md",
],
)
def test_long_filenames_keep_their_extension(raw):
# _save_upload gates on the extension, so trimming it would reject the file.
out = _sanitize_filename(raw)
assert len(out) <= 200
assert os.path.splitext(out)[1].lower() == os.path.splitext(raw)[1].lower()
@pytest.mark.parametrize(
"raw",
[
"../../etc/passwd.txt",
"..\\..\\windows\\evil.txt",
"/absolute/notes.txt",
"C:\\Users\\me\\notes.txt",
],
)
def test_sanitized_filenames_carry_no_path(raw):
out = _sanitize_filename(raw)
assert "/" not in out and "\\" not in out
@pytest.mark.parametrize("raw", ["." * 300, "noext" * 100, "a" * 100 + "." + "e" * 250])
def test_sanitizer_degrades_safely(raw):
assert 0 < len(_sanitize_filename(raw)) <= 200