mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
### What problem does this PR solve?
| # | Method | Endpoint | Description | Git Equivalent |
|---|--------|----------|-------------|----------------|
| 1 | `POST` | `/api/v1/{prefix}/{folder_id}/commits` | Create a
snapshot commit with file changes (add/modify/delete/rename) | `git add`
+ `git commit` |
| 2 | `GET` | `/api/v1/{prefix}/{folder_id}/commits` | List commit
history (paginated) | `git log` |
| 3 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}` | Get
commit detail with file changes | `git show` |
| 4 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files` |
List file changes in a commit | `git show --name-status` |
| 5 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/diff?from=...&to=...` | Compare
two commits and return differences | `git diff` |
| 6 | `GET` | `/api/v1/{prefix}/{folder_id}/changes` | Get uncommitted
changes (add/modify/delete) | `git status` |
| 7 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/tree` |
Get the folder tree snapshot at commit time | `git ls-tree` |
| 8 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files/{file_id}/content`
| Get a file's content as it existed in a specific commit | `git show
HEAD:file` |
| 9 | `GET` | `/api/v1/{prefix}/{file_id}/versions` | Get version
history for a specific file across all commits | `git log -- file` |
Where `{prefix}/{id}` can be:
- `folders/{folder_id}` — direct folder access
- `workspaces/{workspace_id}` — alias of `folders/{folder_id}`
- `datasets/{dataset_id}` — resolves to the dataset's folder
- `memories/{memory_id}` — resolves to the memory's folder
- `skills/{skill_id}` — resolves to the skill's folder
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
- [x] Documentation Update
719 lines
27 KiB
Python
719 lines
27 KiB
Python
#
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
"""API-level integration test for file commit endpoints.
|
|
|
|
Uses an in-memory SQLite database so the real FileCommitService and
|
|
FileCommit/FileCommitItem models execute against real SQL — only the
|
|
HTTP layer (quart.request, login_required, current_user) and storage
|
|
are mocked.
|
|
"""
|
|
|
|
import asyncio
|
|
import functools
|
|
import importlib.util
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
import pytest
|
|
from peewee import SqliteDatabase, Model, CharField, IntegerField, BigIntegerField, TextField
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class _DummyManager:
|
|
def route(self, *_args, **_kwargs):
|
|
def decorator(func):
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
# Shared mutable payload used by both get_request_json (stub) and
|
|
# _setup_request so validate_request's closure always sees the current value.
|
|
_request_payload: list = [{}]
|
|
|
|
|
|
# ── SQLite in-memory models ───────────────────────────────────────────────
|
|
# We create minimal Peewee models that match the real table schemas so
|
|
# FileCommitService (which uses DB.atomic(), .select(), .where(), etc.)
|
|
# works against real SQL.
|
|
|
|
sqlite_db = SqliteDatabase(':memory:')
|
|
|
|
|
|
class BaseTestModel(Model):
|
|
class Meta:
|
|
database = sqlite_db
|
|
|
|
|
|
class FileCommitTestModel(BaseTestModel):
|
|
id = CharField(max_length=32, primary_key=True)
|
|
folder_id = CharField(max_length=32, index=True)
|
|
parent_id = CharField(max_length=32, null=True, index=True)
|
|
message = CharField(max_length=512, default="")
|
|
author_id = CharField(max_length=32, index=True)
|
|
file_count = IntegerField(default=0)
|
|
tree_state = TextField(null=True)
|
|
create_time = BigIntegerField(null=True, index=True)
|
|
create_date = CharField(null=True, max_length=32, index=True)
|
|
update_time = BigIntegerField(null=True, index=True)
|
|
update_date = CharField(null=True, max_length=32, index=True)
|
|
|
|
class Meta:
|
|
db_table = "file_commit"
|
|
|
|
|
|
class FileCommitItemTestModel(BaseTestModel):
|
|
id = CharField(max_length=32, primary_key=True)
|
|
commit_id = CharField(max_length=32, index=True)
|
|
file_id = CharField(max_length=32, index=True)
|
|
operation = CharField(max_length=16, index=True)
|
|
old_hash = CharField(max_length=64, null=True, index=True)
|
|
new_hash = CharField(max_length=64, null=True, index=True)
|
|
old_location = CharField(max_length=255, null=True)
|
|
new_location = CharField(max_length=255, null=True)
|
|
old_name = CharField(max_length=255, null=True)
|
|
new_name = CharField(max_length=255, null=True)
|
|
create_time = BigIntegerField(null=True, index=True)
|
|
create_date = CharField(null=True, max_length=32, index=True)
|
|
update_time = BigIntegerField(null=True, index=True)
|
|
update_date = CharField(null=True, max_length=32, index=True)
|
|
|
|
class Meta:
|
|
db_table = "file_commit_item"
|
|
|
|
|
|
class FileTestModel(BaseTestModel):
|
|
id = CharField(max_length=32, primary_key=True)
|
|
parent_id = CharField(max_length=32, index=True)
|
|
tenant_id = CharField(max_length=32, index=True)
|
|
created_by = CharField(max_length=32, index=True)
|
|
name = CharField(max_length=255, index=True)
|
|
location = CharField(max_length=255, null=True, index=True)
|
|
size = BigIntegerField(default=0, index=True)
|
|
type = CharField(max_length=32, index=True)
|
|
source_type = CharField(max_length=128, default="", index=True)
|
|
status = CharField(max_length=1, null=True, default="1", index=True)
|
|
create_time = BigIntegerField(null=True, index=True)
|
|
create_date = CharField(null=True, max_length=32, index=True)
|
|
update_time = BigIntegerField(null=True, index=True)
|
|
update_date = CharField(null=True, max_length=32, index=True)
|
|
|
|
class Meta:
|
|
db_table = "file"
|
|
|
|
|
|
_TABLES = [FileCommitTestModel, FileCommitItemTestModel, FileTestModel]
|
|
sqlite_db.create_tables(_TABLES)
|
|
|
|
|
|
def _clear_db():
|
|
"""Delete all rows from every test table so each test starts clean."""
|
|
for model in _TABLES:
|
|
model.delete().execute()
|
|
|
|
|
|
# ── Module loader ─────────────────────────────────────────────────────────
|
|
|
|
def _load_module(monkeypatch):
|
|
"""Load file_commit_api.py with SQLite in-memory DB and mocked HTTP layer."""
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
|
|
# Stub: quart.request
|
|
quart_mod = ModuleType("quart")
|
|
quart_mod.request = SimpleNamespace(args={}, content_type="application/json")
|
|
monkeypatch.setitem(sys.modules, "quart", quart_mod)
|
|
|
|
# Stub: api.apps with login_required / current_user
|
|
api_pkg = ModuleType("api")
|
|
api_pkg.__path__ = [str(repo_root / "api")]
|
|
monkeypatch.setitem(sys.modules, "api", api_pkg)
|
|
|
|
apps_mod = ModuleType("api.apps")
|
|
apps_mod.__path__ = [str(repo_root / "api" / "apps")]
|
|
apps_mod.current_user = SimpleNamespace(id="test-user")
|
|
apps_mod.login_required = lambda func: func
|
|
monkeypatch.setitem(sys.modules, "api.apps", apps_mod)
|
|
api_pkg.apps = apps_mod
|
|
|
|
# Stub: api.utils.api_utils
|
|
api_utils_mod = ModuleType("api.utils.api_utils")
|
|
|
|
def get_json_result(data=None, message="success", code=0):
|
|
return {"code": code, "data": data, "message": message}
|
|
|
|
def get_data_error_result(message=""):
|
|
return {"code": 102, "data": None, "message": message}
|
|
|
|
async def get_request_json():
|
|
return _request_payload[0]
|
|
|
|
def server_error_response(err):
|
|
return {"code": 500, "data": None, "message": str(err)}
|
|
|
|
def validate_request(*required_keys):
|
|
def _decorator(func):
|
|
@functools.wraps(func)
|
|
async def _wrapper(*args, **kwargs):
|
|
payload = await get_request_json()
|
|
missing = [k for k in required_keys if k not in payload]
|
|
if missing:
|
|
return get_json_result(
|
|
code=101, data=None,
|
|
message="required argument are missing: " + ", ".join(missing)
|
|
)
|
|
return await func(*args, **kwargs)
|
|
return _wrapper
|
|
return _decorator
|
|
|
|
api_utils_mod.get_json_result = get_json_result
|
|
api_utils_mod.get_data_error_result = get_data_error_result
|
|
api_utils_mod.get_request_json = get_request_json
|
|
api_utils_mod.server_error_response = server_error_response
|
|
api_utils_mod.validate_request = validate_request
|
|
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
|
|
|
|
# Stub: common.misc_utils
|
|
import uuid
|
|
misc_utils_mod = ModuleType("common.misc_utils")
|
|
misc_utils_mod.get_uuid = lambda: uuid.uuid1().hex
|
|
monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod)
|
|
|
|
# Stub: common.settings (STORAGE_IMPL is a no-op for testing)
|
|
common_mod = ModuleType("common")
|
|
common_mod.__path__ = []
|
|
common_mod.settings = SimpleNamespace(
|
|
STORAGE_IMPL=SimpleNamespace(
|
|
put=lambda *_a, **_kw: None,
|
|
get=lambda *_a, **_kw: b"stub-content",
|
|
),
|
|
DATABASE_TYPE="sqlite",
|
|
)
|
|
monkeypatch.setitem(sys.modules, "common", common_mod)
|
|
|
|
# Stub: common.time_utils (monotonically increasing timestamps)
|
|
_ts_iter = iter(range(1718200000000, 1718200000100))
|
|
time_utils_mod = ModuleType("common.time_utils")
|
|
time_utils_mod.current_timestamp = lambda: next(_ts_iter)
|
|
time_utils_mod.datetime_format = lambda *_a, **__: "mock"
|
|
monkeypatch.setitem(sys.modules, "common.time_utils", time_utils_mod)
|
|
|
|
# Stub: api.db.db_models — inject SQLite DB and our test models
|
|
db_models_mod = ModuleType("api.db.db_models")
|
|
|
|
class _DB:
|
|
"""Drop-in replacement that wraps our SQLite DB with the same
|
|
methods (connection_context, atomic) that CommonService expects."""
|
|
|
|
@staticmethod
|
|
def connection_context():
|
|
def dec(func):
|
|
return func
|
|
return dec
|
|
|
|
@staticmethod
|
|
def atomic():
|
|
class Ctx:
|
|
def __enter__(self2):
|
|
return self2
|
|
def __exit__(self2, *args):
|
|
pass
|
|
return Ctx()
|
|
|
|
db_models_mod.DB = _DB
|
|
db_models_mod.FileCommit = FileCommitTestModel
|
|
db_models_mod.FileCommitItem = FileCommitItemTestModel
|
|
db_models_mod.File = FileTestModel
|
|
db_models_mod.DataBaseModel = BaseTestModel
|
|
monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod)
|
|
|
|
class _StubFileService:
|
|
model = FileTestModel # class attribute, not staticmethod — code accesses FileService.model.update(...)
|
|
@staticmethod
|
|
def update_by_id(pid, data):
|
|
return FileTestModel.update(data).where(FileTestModel.id == pid).execute()
|
|
@staticmethod
|
|
def get_by_id(pid):
|
|
try:
|
|
obj = FileTestModel.get_by_id(pid)
|
|
return True, obj
|
|
except Exception:
|
|
return False, None
|
|
@staticmethod
|
|
def get_or_none(**kwargs):
|
|
try:
|
|
return FileTestModel.get(**kwargs)
|
|
except Exception:
|
|
return None
|
|
|
|
class CommonServiceBase:
|
|
model = None
|
|
@classmethod
|
|
def get_by_id(cls, pid):
|
|
try:
|
|
obj = cls.model.get_or_none(cls.model.id == pid)
|
|
if obj:
|
|
return True, obj
|
|
except Exception:
|
|
pass
|
|
return False, None
|
|
@classmethod
|
|
def query(cls, cols=None, reverse=None, order_by=None, **kwargs):
|
|
q = cls.model.select()
|
|
for f_n, f_v in kwargs.items():
|
|
if f_v is not None and hasattr(cls.model, f_n):
|
|
q = q.where(getattr(cls.model, f_n) == f_v)
|
|
return q
|
|
@classmethod
|
|
def update_by_id(cls, pid, data):
|
|
return cls.model.update(data).where(cls.model.id == pid).execute()
|
|
@classmethod
|
|
def filter_update(cls, filters, update_data):
|
|
return cls.model.update(update_data).where(*filters).execute()
|
|
|
|
# Stub: common.constants with FileSource for resolver
|
|
constants_mod = ModuleType("common.constants")
|
|
constants_mod.FileSource = type("FileSource", (), {"KNOWLEDGEBASE": "knowledgebase"})
|
|
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
|
|
|
|
# Stub: api.db with real filesystem path so sub-packages can be discovered.
|
|
db_pkg = ModuleType("api.db")
|
|
db_pkg.__path__ = [str(repo_root / "api" / "db")]
|
|
db_pkg.UserTenantRole = type('UserTenantRole', (), {k: k for k in ('OWNER','ADMIN','NORMAL','INVITE')})
|
|
db_pkg.TenantPermission = type('TenantPermission', (), {'ME': 'me', 'TEAM': 'team'})
|
|
db_pkg.FileType = type('FileType', (), {'FOLDER': 'folder', 'DOC': 'doc', 'VISUAL': 'visual', 'AURAL': 'aural', 'VIRTUAL': 'virtual', 'PDF': 'pdf', 'OTHER': 'other'})
|
|
db_pkg.KNOWLEDGEBASE_FOLDER_NAME = '.knowledgebase'
|
|
db_pkg.SKILLS_FOLDER_NAME = 'skills'
|
|
monkeypatch.setitem(sys.modules, "api.db", db_pkg)
|
|
api_pkg.db = db_pkg
|
|
|
|
# Stub api.db.services — prevent real __init__ from loading (avoids
|
|
# importing every real service module). Keep the filesystem path so
|
|
# file_commit_service can be discovered, but pre-stub file_service
|
|
# (which has heavy deps that would cascade-fail).
|
|
services_pkg = ModuleType("api.db.services")
|
|
services_pkg.__path__ = [str(repo_root / "api" / "db" / "services")]
|
|
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
|
|
|
|
# Pre-stub service modules that file_commit_api.py imports.
|
|
# Each stub prevents the real .py file from loading (and cascading deps).
|
|
file_svc_mod = ModuleType("api.db.services.file_service")
|
|
file_svc_mod.FileService = _StubFileService
|
|
monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_svc_mod)
|
|
|
|
common_svc_mod = ModuleType("api.db.services.common_service")
|
|
common_svc_mod.CommonService = CommonServiceBase
|
|
monkeypatch.setitem(sys.modules, "api.db.services.common_service", common_svc_mod)
|
|
|
|
kb_svc_mod = ModuleType("api.db.services.knowledgebase_service")
|
|
# NB: The dataset resolver in the API calls KnowledgebaseService.get_by_id
|
|
# then accesses .name and .tenant_id. We return a simple object.
|
|
class _StubKnowledgebaseService:
|
|
@staticmethod
|
|
def get_by_id(dataset_id):
|
|
if dataset_id == "ds-1":
|
|
return True, SimpleNamespace(name="test-ds", tenant_id="t1")
|
|
return False, None
|
|
kb_svc_mod.KnowledgebaseService = _StubKnowledgebaseService
|
|
monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", kb_svc_mod)
|
|
|
|
# Remove cached file_commit_service so it reimports with our SQLite stubs.
|
|
# Keep api.db.db_models in sys.modules — it's already patched above.
|
|
for mod_name in list(sys.modules.keys()):
|
|
if mod_name.startswith("api.db.services.file_commit"):
|
|
del sys.modules[mod_name]
|
|
|
|
# Load the module
|
|
module_name = "api.apps.restful_apis.file_commit_api"
|
|
module_path = repo_root / "api" / "apps" / "restful_apis" / "file_commit_api.py"
|
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
module.manager = _DummyManager()
|
|
monkeypatch.setitem(sys.modules, module_name, module)
|
|
spec.loader.exec_module(module)
|
|
|
|
return module
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
def _setup_request(module, json_payload=None, args=None):
|
|
"""Set up a request payload and query args for the next handler call."""
|
|
if json_payload is not None:
|
|
_request_payload[0] = json_payload
|
|
if args is not None:
|
|
module.request.args = args
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture(scope="session")
|
|
def auth():
|
|
return "test-auth"
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def set_tenant_info():
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_db():
|
|
"""Clear all rows before each test to prevent order-dependent failures."""
|
|
_clear_db()
|
|
|
|
|
|
# ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.p2
|
|
def test_create_commit_success(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
# Seed a file
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "initial commit",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "hello"}],
|
|
})
|
|
|
|
res = _run(module.create_commit("root-folder"))
|
|
assert res["code"] == 0, f"Expected 0, got {res}"
|
|
data = res["data"]
|
|
assert data["message"] == "initial commit"
|
|
assert data["folder_id"] == "root-folder"
|
|
assert data["author_id"] == "test-user"
|
|
assert data["file_count"] == 1
|
|
assert data["tree_state"] is not None
|
|
assert data["id"] is not None
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_create_commit_missing_fields(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
_setup_request(module, json_payload={"message": "no files"})
|
|
|
|
res = _run(module.create_commit("root-folder"))
|
|
assert res["code"] == 101, f"Expected validation error, got {res}"
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_create_commit_modify_and_add(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
FileTestModel.create(id="f2", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="b.txt", type="txt")
|
|
|
|
# Commit 1: add f1
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "v1"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
# Commit 2: modify f1, add f2
|
|
_setup_request(module, json_payload={
|
|
"message": "c2",
|
|
"files": [
|
|
{"file_id": "f1", "file_name": "a.txt", "operation": "modify", "content": "v2"},
|
|
{"file_id": "f2", "file_name": "b.txt", "operation": "add", "content": "world"},
|
|
],
|
|
})
|
|
res = _run(module.create_commit("root-folder"))
|
|
assert res["code"] == 0
|
|
assert res["data"]["file_count"] == 2
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_create_commit_delete(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
# Add then delete
|
|
_setup_request(module, json_payload={
|
|
"message": "add",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "hello"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "delete",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "delete"}],
|
|
})
|
|
res = _run(module.create_commit("root-folder"))
|
|
assert res["code"] == 0
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_create_commit_rename(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="old.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "add",
|
|
"files": [{"file_id": "f1", "file_name": "old.txt", "operation": "add", "content": "data"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
# Rename
|
|
_setup_request(module, json_payload={
|
|
"message": "rename",
|
|
"files": [{"file_id": "f1", "file_name": "old.txt", "operation": "rename",
|
|
"old_name": "old.txt", "new_name": "new.txt"}],
|
|
})
|
|
res = _run(module.create_commit("root-folder"))
|
|
assert res["code"] == 0
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_list_commits_success(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
# Create 2 commits
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "v1"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "c2",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "modify", "content": "v2"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
# List
|
|
module.request.args = {"page": "1", "page_size": "10"}
|
|
res = _run(module.list_commits("root-folder"))
|
|
assert res["code"] == 0
|
|
assert res["data"]["total"] == 2
|
|
assert len(res["data"]["commits"]) == 2
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_commit_detail(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "detail test",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "data"}],
|
|
})
|
|
create_res = _run(module.create_commit("root-folder"))
|
|
commit_id = create_res["data"]["id"]
|
|
|
|
res = _run(module.get_commit("root-folder", commit_id))
|
|
assert res["code"] == 0
|
|
assert res["data"]["id"] == commit_id
|
|
assert res["data"]["message"] == "detail test"
|
|
assert len(res["data"]["files"]) == 1
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_commit_not_found(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
res = _run(module.get_commit("root-folder", "nonexistent"))
|
|
assert res["code"] == 102
|
|
assert "not found" in res["message"].lower()
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_diff_commits(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
FileTestModel.create(id="f2", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="b.txt", type="txt")
|
|
|
|
# c1: add f1
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "v1"}],
|
|
})
|
|
c1 = _run(module.create_commit("root-folder"))["data"]["id"]
|
|
|
|
# c2: add f2, modify f1
|
|
_setup_request(module, json_payload={
|
|
"message": "c2",
|
|
"files": [
|
|
{"file_id": "f2", "file_name": "b.txt", "operation": "add", "content": "world"},
|
|
{"file_id": "f1", "file_name": "a.txt", "operation": "modify", "content": "v2"},
|
|
],
|
|
})
|
|
c2 = _run(module.create_commit("root-folder"))["data"]["id"]
|
|
assert c1 != c2, "c1 and c2 must have different IDs"
|
|
|
|
module.request.args = {"from": c1, "to": c2}
|
|
res = _run(module.diff_commits("root-folder"))
|
|
assert res["code"] == 0, f"diff failed: {res}"
|
|
assert len(res["data"]) == 2, f"Expected 2 diff entries, got {len(res['data'])}: {res['data']}"
|
|
|
|
# Verify f2 was added (present in c2 but not in c1)
|
|
f2_entries = [e for e in res["data"] if e["file_id"] == "f2"]
|
|
assert len(f2_entries) == 1
|
|
assert f2_entries[0]["operation"] == "add"
|
|
|
|
# Verify f1 was modified (hash changed from v1 to v2)
|
|
f1_entries = [e for e in res["data"] if e["file_id"] == "f1"]
|
|
assert len(f1_entries) == 1
|
|
assert f1_entries[0]["operation"] == "modify"
|
|
assert f1_entries[0]["old_hash"] != f1_entries[0]["new_hash"]
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_diff_commits_missing_params(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
module.request.args = {}
|
|
res = _run(module.diff_commits("root-folder"))
|
|
assert res["code"] == 102
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_uncommitted_changes(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
# Seed a file that will be committed
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
# Seed a file that will NOT be committed (uncommitted add)
|
|
FileTestModel.create(id="f2", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="b.txt", type="txt")
|
|
|
|
# Commit only f1
|
|
_setup_request(module, json_payload={
|
|
"message": "add f1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "hello"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
res = _run(module.get_uncommitted_changes("root-folder"))
|
|
assert res["code"] == 0
|
|
# f2 should appear as uncommitted "add"
|
|
f2_changes = [c for c in res["data"] if c["file_id"] == "f2"]
|
|
assert len(f2_changes) > 0, "Expected f2 to show as uncommitted change"
|
|
assert f2_changes[0]["operation"] == "add"
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_commit_tree(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "data"}],
|
|
})
|
|
create_res = _run(module.create_commit("root-folder"))
|
|
commit_id = create_res["data"]["id"]
|
|
|
|
res = _run(module.get_commit_tree("root-folder", commit_id))
|
|
assert res["code"] == 0
|
|
assert res["data"]["type"] == "folder"
|
|
assert res["data"]["id"] == "root-folder"
|
|
assert any(c["id"] == "f1" for c in res["data"].get("children", []))
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_commit_file_content(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "hello world"}],
|
|
})
|
|
create_res = _run(module.create_commit("root-folder"))
|
|
commit_id = create_res["data"]["id"]
|
|
|
|
res = _run(module.get_commit_file_content("root-folder", commit_id, "f1"))
|
|
assert res["code"] == 0
|
|
assert "content" in res["data"]
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_file_version_history(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="root-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
# Two commits modifying f1
|
|
_setup_request(module, json_payload={
|
|
"message": "v1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "v1"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "v2",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "modify", "content": "v2"}],
|
|
})
|
|
_run(module.create_commit("root-folder"))
|
|
|
|
res = _run(module.get_file_version_history("f1"))
|
|
assert res["code"] == 0
|
|
assert len(res["data"]) == 2
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_workspace_alias(monkeypatch):
|
|
"""Verify /workspace/ alias routes work the same as /folders/."""
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="ws-folder", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "workspace commit",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "data"}],
|
|
})
|
|
res = _run(module.create_commit("ws-folder"))
|
|
assert res["code"] == 0
|
|
|
|
# List via workspace alias
|
|
module.request.args = {"page": "1", "page_size": "10"}
|
|
res = _run(module.list_commits("ws-folder"))
|
|
assert res["code"] == 0
|
|
assert res["data"]["total"] == 1
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_get_commit_wrong_folder_returns_not_found(monkeypatch):
|
|
module = _load_module(monkeypatch)
|
|
FileTestModel.create(id="f1", parent_id="folder-a", tenant_id="t1",
|
|
created_by="test-user", name="a.txt", type="txt")
|
|
|
|
_setup_request(module, json_payload={
|
|
"message": "c1",
|
|
"files": [{"file_id": "f1", "file_name": "a.txt", "operation": "add", "content": "data"}],
|
|
})
|
|
create_res = _run(module.create_commit("folder-a"))
|
|
commit_id = create_res["data"]["id"]
|
|
|
|
# Attempt to read commit from a different folder
|
|
res = _run(module.get_commit("folder-b", commit_id))
|
|
assert res["code"] == 102
|
|
assert "not found in workspace" in res["message"].lower()
|