chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
class FakeCollection:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[str, str] = {}
|
||||
self.chunks: dict[str, list[dict]] = {}
|
||||
self.get_calls: list[dict] = []
|
||||
self.queries: list[dict] = []
|
||||
self.contains_queries: list[dict] = []
|
||||
|
||||
async def get(self, **kwargs):
|
||||
self.get_calls.append(kwargs)
|
||||
ids = kwargs.get("ids")
|
||||
if ids == ["__path_tree__"]:
|
||||
return {"documents": [self.documents["__path_tree__"]]}
|
||||
|
||||
where = kwargs.get("where") or {}
|
||||
slug = where.get("page_slug")
|
||||
if isinstance(slug, dict):
|
||||
slug = slug.get("$eq")
|
||||
if slug is not None:
|
||||
chunks = self.chunks.get(slug, [])
|
||||
offset = kwargs.get("offset") or 0
|
||||
limit = kwargs.get("limit")
|
||||
if limit is not None:
|
||||
chunks = chunks[offset:offset + limit]
|
||||
elif offset:
|
||||
chunks = chunks[offset:]
|
||||
return {
|
||||
"documents": [item["document"] for item in chunks],
|
||||
"metadatas": [item["metadata"] for item in chunks],
|
||||
}
|
||||
|
||||
where_document = kwargs.get("where_document") or {}
|
||||
if "$contains" in where_document or "$regex" in where_document:
|
||||
self.contains_queries.append(kwargs)
|
||||
candidates = where.get("page_slug", {}).get("$in", [])
|
||||
pattern = where_document.get("$contains") or where_document.get(
|
||||
"$regex")
|
||||
docs: list[str] = []
|
||||
metadatas: list[dict] = []
|
||||
for slug_item in candidates:
|
||||
for chunk in self.chunks.get(slug_item, []):
|
||||
if pattern in chunk["document"]:
|
||||
docs.append(chunk["document"])
|
||||
metadatas.append(chunk["metadata"])
|
||||
return {"documents": docs, "metadatas": metadatas}
|
||||
|
||||
return {"documents": [], "metadatas": []}
|
||||
|
||||
async def query(self, **kwargs):
|
||||
self.queries.append(kwargs)
|
||||
return {
|
||||
"documents": [["quickstart chunk", "api chunk"]],
|
||||
"metadatas": [[{
|
||||
"page_slug": "guides/quickstart"
|
||||
}, {
|
||||
"page_slug": "api/reference"
|
||||
}]],
|
||||
"distances": [[0.1, 0.25]],
|
||||
}
|
||||
|
||||
|
||||
def path_tree_document() -> str:
|
||||
return json.dumps({
|
||||
"guides/quickstart": {
|
||||
"size": 12,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-02-01T00:00:00Z",
|
||||
},
|
||||
"api/reference": {
|
||||
"size": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chroma_collection() -> FakeCollection:
|
||||
collection = FakeCollection()
|
||||
collection.documents["__path_tree__"] = path_tree_document()
|
||||
collection.chunks["guides/quickstart"] = [
|
||||
{
|
||||
"document": "second",
|
||||
"metadata": {
|
||||
"page_slug": "guides/quickstart",
|
||||
"chunk_index": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
"document": "first",
|
||||
"metadata": {
|
||||
"page_slug": "guides/quickstart",
|
||||
"chunk_index": 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
collection.chunks["api/reference"] = [{
|
||||
"document": "api",
|
||||
"metadata": {
|
||||
"page_slug": "api/reference",
|
||||
"chunk_index": 0,
|
||||
},
|
||||
}]
|
||||
return collection
|
||||
|
||||
|
||||
async def _get_collection(collection):
|
||||
return collection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chroma_accessor(chroma_collection) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(slug_field="page_slug",
|
||||
chunk_index_field="chunk_index"),
|
||||
collection=chroma_collection,
|
||||
get_collection=partial(_get_collection, chroma_collection))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chroma_index() -> RAMIndexCacheStore:
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def knowledge_root() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/knowledge", "/knowledge"),
|
||||
virtual="/knowledge",
|
||||
directory="/knowledge")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quickstart_path() -> PathSpec:
|
||||
return PathSpec.from_str_path(
|
||||
"/knowledge/guides/quickstart",
|
||||
mount_key("/knowledge/guides/quickstart", "/knowledge"))
|
||||
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma._client import (PAGE_CHUNK_BATCH_SIZE,
|
||||
fetch_page_chunks, fetch_path_tree,
|
||||
page_chunks, query_contains)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_path_tree(chroma_accessor):
|
||||
raw = await fetch_path_tree(chroma_accessor)
|
||||
assert "guides/quickstart" in raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_path_tree_missing_raises(chroma_accessor,
|
||||
chroma_collection):
|
||||
del chroma_collection.documents["__path_tree__"]
|
||||
chroma_collection.get = _empty_get
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await fetch_path_tree(chroma_accessor)
|
||||
|
||||
|
||||
async def _empty_get(**kwargs):
|
||||
return {"documents": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_chunks_sorted_by_chunk_index(chroma_accessor):
|
||||
chunks = await page_chunks(chroma_accessor, "guides/quickstart")
|
||||
assert [c["document"] for c in chunks] == ["first", "second"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_page_chunks_joins_with_newline(chroma_accessor):
|
||||
text = await fetch_page_chunks(chroma_accessor, "guides/quickstart")
|
||||
assert text == "first\nsecond"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_chunks_paginates(chroma_accessor, chroma_collection):
|
||||
chroma_collection.chunks["big/page"] = [{
|
||||
"document": f"chunk-{i}",
|
||||
"metadata": {
|
||||
"page_slug": "big/page",
|
||||
"chunk_index": i
|
||||
},
|
||||
} for i in range(PAGE_CHUNK_BATCH_SIZE + 5)]
|
||||
chunks = await page_chunks(chroma_accessor, "big/page")
|
||||
assert len(chunks) == PAGE_CHUNK_BATCH_SIZE + 5
|
||||
assert chunks[0]["document"] == "chunk-0"
|
||||
assert chunks[-1]["document"] == f"chunk-{PAGE_CHUNK_BATCH_SIZE + 4}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_contains_scopes_to_candidates(chroma_accessor):
|
||||
matched = await query_contains(chroma_accessor, "first",
|
||||
["guides/quickstart", "api/reference"])
|
||||
assert matched == ["guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_contains_empty_candidates_short_circuits(
|
||||
chroma_accessor, chroma_collection):
|
||||
matched = await query_contains(chroma_accessor, "first", [])
|
||||
assert matched == []
|
||||
assert not chroma_collection.contains_queries
|
||||
@@ -0,0 +1,82 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.find import find
|
||||
from mirage.types import FindType
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_all(chroma_accessor, chroma_index, knowledge_root):
|
||||
results = await find(chroma_accessor, knowledge_root, index=chroma_index)
|
||||
assert "/guides/quickstart" in results
|
||||
assert "/api/reference" in results
|
||||
assert "/guides" in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_name_matches_mount_root_start_path(chroma_accessor,
|
||||
chroma_index,
|
||||
knowledge_root):
|
||||
results = await find(chroma_accessor,
|
||||
knowledge_root,
|
||||
name="knowledge",
|
||||
index=chroma_index)
|
||||
assert results == ["/"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_by_name(chroma_accessor, chroma_index, knowledge_root):
|
||||
results = await find(chroma_accessor,
|
||||
knowledge_root,
|
||||
name="quick*",
|
||||
index=chroma_index)
|
||||
assert results == ["/guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_file(chroma_accessor, chroma_index, knowledge_root):
|
||||
results = await find(chroma_accessor,
|
||||
knowledge_root,
|
||||
type=FindType.FILE,
|
||||
index=chroma_index)
|
||||
assert sorted(results) == ["/api/reference", "/guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_directory(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await find(chroma_accessor,
|
||||
knowledge_root,
|
||||
type=FindType.DIRECTORY,
|
||||
index=chroma_index)
|
||||
assert "/guides" in results
|
||||
assert "/guides/quickstart" not in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_iname_case_insensitive(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await find(chroma_accessor,
|
||||
knowledge_root,
|
||||
iname="QUICK*",
|
||||
index=chroma_index)
|
||||
assert results == ["/guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_missing_index_raises(chroma_accessor, knowledge_root):
|
||||
with pytest.raises(ValueError, match="missing index"):
|
||||
await find(chroma_accessor, knowledge_root, index=None)
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.glob import resolve_glob
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_path_passes_through(chroma_accessor, chroma_index,
|
||||
quickstart_path):
|
||||
result = await resolve_glob(chroma_accessor, [quickstart_path],
|
||||
chroma_index)
|
||||
assert result == [quickstart_path]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pattern_expands_against_readdir(chroma_accessor, chroma_index):
|
||||
pattern = PathSpec(resource_path=mount_key("/knowledge/guides/quick*",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/quick*",
|
||||
directory="/knowledge/guides",
|
||||
pattern="quick*",
|
||||
resolved=False)
|
||||
result = await resolve_glob(chroma_accessor, [pattern], chroma_index)
|
||||
assert [p.virtual for p in result] == ["/knowledge/guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pattern_without_match_stays_literal(chroma_accessor,
|
||||
chroma_index):
|
||||
# bash with nullglob off: the unmatched glob word stays the literal so
|
||||
# the command errors on it like GNU.
|
||||
pattern = PathSpec(resource_path=mount_key("/knowledge/guides/*.zip",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/*.zip",
|
||||
directory="/knowledge/guides",
|
||||
pattern="*.zip",
|
||||
resolved=False)
|
||||
result = await resolve_glob(chroma_accessor, [pattern], chroma_index)
|
||||
assert [p.virtual for p in result] == ["/knowledge/guides/*.zip"]
|
||||
assert result[0].resolved
|
||||
assert result[0].pattern is None
|
||||
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.path import resolve_path, virtual_key_for
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_file(chroma_accessor, chroma_index, quickstart_path):
|
||||
resolved = await resolve_path(chroma_accessor, quickstart_path,
|
||||
chroma_index)
|
||||
assert not resolved.is_dir
|
||||
assert resolved.entry is not None
|
||||
assert resolved.entry.extra["slug"] == "guides/quickstart"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_directory(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path("/knowledge/guides",
|
||||
mount_key("/knowledge/guides", "/knowledge"))
|
||||
resolved = await resolve_path(chroma_accessor, path, chroma_index)
|
||||
assert resolved.is_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_mount_root(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
resolved = await resolve_path(chroma_accessor, knowledge_root,
|
||||
chroma_index)
|
||||
assert resolved.is_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_raises(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path(
|
||||
"/knowledge/missing", mount_key("/knowledge/missing", "/knowledge"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await resolve_path(chroma_accessor, path, chroma_index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_str_path_coerced(chroma_accessor, chroma_index):
|
||||
resolved = await resolve_path(chroma_accessor, "/guides/quickstart",
|
||||
chroma_index)
|
||||
assert not resolved.is_dir
|
||||
|
||||
|
||||
def test_virtual_key_for_prefixed():
|
||||
path = PathSpec.from_str_path(
|
||||
"/knowledge/guides/quickstart",
|
||||
mount_key("/knowledge/guides/quickstart", "/knowledge"))
|
||||
assert virtual_key_for(path) == "/knowledge/guides/quickstart"
|
||||
|
||||
|
||||
def test_virtual_key_for_root():
|
||||
path = PathSpec(resource_path=mount_key("/knowledge", "/knowledge"),
|
||||
virtual="/knowledge",
|
||||
directory="/knowledge")
|
||||
assert virtual_key_for(path) == "/knowledge"
|
||||
|
||||
|
||||
def test_virtual_key_for_unprefixed():
|
||||
path = PathSpec(resource_path="guides/quickstart",
|
||||
virtual="/guides/quickstart",
|
||||
directory="/guides")
|
||||
assert virtual_key_for(path) == "/guides/quickstart"
|
||||
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma import _client, read
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_chunks_reads_in_batches(monkeypatch, chroma_accessor,
|
||||
chroma_collection):
|
||||
monkeypatch.setattr(_client, "PAGE_CHUNK_BATCH_SIZE", 1)
|
||||
|
||||
chunks = await _client.page_chunks(chroma_accessor, "guides/quickstart")
|
||||
|
||||
assert [chunk["document"] for chunk in chunks] == ["first", "second"]
|
||||
page_calls = [
|
||||
call for call in chroma_collection.get_calls if call.get("where") == {
|
||||
"page_slug": "guides/quickstart"
|
||||
}
|
||||
]
|
||||
assert [call["limit"] for call in page_calls] == [1, 1, 1]
|
||||
assert [call["offset"] for call in page_calls] == [0, 1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_reassembles_sorted_chunks(chroma_accessor,
|
||||
chroma_index,
|
||||
quickstart_path):
|
||||
data = await read.read_bytes(chroma_accessor, quickstart_path,
|
||||
chroma_index)
|
||||
|
||||
assert data == b"first\nsecond"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_yields_sorted_chunks(chroma_accessor, chroma_index,
|
||||
quickstart_path):
|
||||
chunks = [
|
||||
chunk async for chunk in read.read_stream(
|
||||
chroma_accessor, quickstart_path, chroma_index)
|
||||
]
|
||||
|
||||
assert chunks == [b"first", b"\n", b"second"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_rejects_directories(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await read.read_bytes(chroma_accessor, knowledge_root, chroma_index)
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.readdir import readdir
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_lists_top_level(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
entries = await readdir(chroma_accessor, knowledge_root, chroma_index)
|
||||
assert sorted(entries) == ["/knowledge/api", "/knowledge/guides"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_subdir(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path("/knowledge/guides",
|
||||
mount_key("/knowledge/guides", "/knowledge"))
|
||||
entries = await readdir(chroma_accessor, path, chroma_index)
|
||||
assert entries == ["/knowledge/guides/quickstart"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_on_file_raises(chroma_accessor, chroma_index,
|
||||
quickstart_path):
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await readdir(chroma_accessor, quickstart_path, chroma_index)
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma import search
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_scopes_folder_to_candidate_slugs(
|
||||
chroma_accessor, chroma_index):
|
||||
result = await search.search_segments(
|
||||
chroma_accessor,
|
||||
"setup",
|
||||
[
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides",
|
||||
directory="/knowledge/guides")
|
||||
],
|
||||
chroma_index,
|
||||
top_k=3,
|
||||
)
|
||||
|
||||
assert result == b"/knowledge/guides/quickstart:0.90\nquickstart chunk\n"
|
||||
assert chroma_accessor.collection.queries[0]["where"] == {
|
||||
"page_slug": {
|
||||
"$in": ["guides/quickstart"]
|
||||
}
|
||||
}
|
||||
assert chroma_accessor.collection.queries[0]["query_texts"] == ["setup"]
|
||||
assert chroma_accessor.collection.queries[0]["n_results"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_empty_paths_searches_collection(
|
||||
chroma_accessor, chroma_index):
|
||||
result = await search.search_segments(chroma_accessor,
|
||||
"setup", [],
|
||||
chroma_index,
|
||||
mount_prefix="/knowledge/")
|
||||
|
||||
assert result == (b"/knowledge/guides/quickstart:0.90\nquickstart chunk\n"
|
||||
b"/knowledge/api/reference:0.75\napi chunk\n")
|
||||
assert "where" not in chroma_accessor.collection.queries[0]
|
||||
|
||||
|
||||
def test_validate_args():
|
||||
with pytest.raises(ValueError, match="query is required"):
|
||||
search.validate_args("", 10)
|
||||
with pytest.raises(ValueError, match="top-k must be positive"):
|
||||
search.validate_args("docs", 0)
|
||||
@@ -0,0 +1,53 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.stat import stat, stat_name
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file(chroma_accessor, chroma_index, quickstart_path):
|
||||
result = await stat(chroma_accessor, quickstart_path, chroma_index)
|
||||
assert result.type == FileType.TEXT
|
||||
assert result.name == "quickstart"
|
||||
assert result.size == 12
|
||||
assert result.modified == "2026-02-01T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path("/knowledge/guides",
|
||||
mount_key("/knowledge/guides", "/knowledge"))
|
||||
result = await stat(chroma_accessor, path, chroma_index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "guides"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_missing_raises(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path(
|
||||
"/knowledge/missing", mount_key("/knowledge/missing", "/knowledge"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(chroma_accessor, path, chroma_index)
|
||||
|
||||
|
||||
def test_stat_name_root():
|
||||
assert stat_name("/knowledge", "/knowledge/") == "/"
|
||||
|
||||
|
||||
def test_stat_name_nested():
|
||||
assert stat_name("/knowledge/guides", "/knowledge/") == "guides"
|
||||
@@ -0,0 +1,58 @@
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma import tree
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_tree_builds_prefixed_entries_from_path_tree(
|
||||
chroma_accessor, chroma_index):
|
||||
await tree.ensure_tree(chroma_accessor, chroma_index, "/knowledge/")
|
||||
|
||||
root = await chroma_index.list_dir("/knowledge")
|
||||
guides = await chroma_index.list_dir("/knowledge/guides")
|
||||
api = await chroma_index.list_dir("/knowledge/api")
|
||||
quickstart = await chroma_index.get("/knowledge/guides/quickstart")
|
||||
|
||||
assert root.entries == ["/knowledge/api", "/knowledge/guides"]
|
||||
assert guides.entries == ["/knowledge/guides/quickstart"]
|
||||
assert api.entries == ["/knowledge/api/reference"]
|
||||
assert quickstart.entry.id == "guides/quickstart"
|
||||
assert quickstart.entry.size == 12
|
||||
assert quickstart.entry.remote_time == "2026-02-01T00:00:00Z"
|
||||
assert quickstart.entry.extra == {
|
||||
"slug": "guides/quickstart",
|
||||
"size": 12,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-02-01T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_tree_uses_cached_tree(chroma_accessor, chroma_index):
|
||||
await tree.ensure_tree(chroma_accessor, chroma_index, "/knowledge/")
|
||||
chroma_accessor.collection.documents["__path_tree__"] = json.dumps(
|
||||
{"other": {}})
|
||||
|
||||
await tree.ensure_tree(chroma_accessor, chroma_index, "/knowledge/")
|
||||
|
||||
assert (await
|
||||
chroma_index.get("/knowledge/guides/quickstart")).entry is not None
|
||||
assert (await chroma_index.get("/knowledge/other")).entry is None
|
||||
|
||||
|
||||
def test_parse_path_tree_accepts_gzip_base64():
|
||||
encoded = base64.b64encode(gzip.compress(b'{"docs/a": {}}')).decode()
|
||||
|
||||
assert tree.parse_path_tree(encoded) == {"docs/a": {}}
|
||||
|
||||
|
||||
def test_build_dir_entries_rejects_duplicate_and_collision():
|
||||
with pytest.raises(ValueError, match="Duplicate Chroma path"):
|
||||
tree.build_dir_entries({"a": {}, "/a/": {}}, "")
|
||||
|
||||
with pytest.raises(ValueError, match="Path collision"):
|
||||
tree.build_dir_entries({"a": {}, "a/b": {}}, "")
|
||||
@@ -0,0 +1,78 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.chroma.walk import walk
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_recurses_all(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await walk(chroma_accessor, knowledge_root, chroma_index)
|
||||
assert "/knowledge/guides/quickstart" in results
|
||||
assert "/knowledge/api/reference" in results
|
||||
assert "/knowledge/guides" in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_include_root(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await walk(chroma_accessor,
|
||||
knowledge_root,
|
||||
chroma_index,
|
||||
include_root=True)
|
||||
assert results[0] == "/knowledge"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_maxdepth_limits(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await walk(chroma_accessor,
|
||||
knowledge_root,
|
||||
chroma_index,
|
||||
maxdepth=1)
|
||||
assert "/knowledge/guides" in results
|
||||
assert "/knowledge/guides/quickstart" not in results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_strip_prefix(chroma_accessor, chroma_index,
|
||||
knowledge_root):
|
||||
results = await walk(chroma_accessor,
|
||||
knowledge_root,
|
||||
chroma_index,
|
||||
strip_prefix=True)
|
||||
assert all(not item.startswith("/knowledge") for item in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_missing_raises(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path(
|
||||
"/knowledge/missing", mount_key("/knowledge/missing", "/knowledge"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await walk(chroma_accessor, path, chroma_index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_missing_ignored(chroma_accessor, chroma_index):
|
||||
path = PathSpec.from_str_path(
|
||||
"/knowledge/missing", mount_key("/knowledge/missing", "/knowledge"))
|
||||
results = await walk(chroma_accessor,
|
||||
path,
|
||||
chroma_index,
|
||||
ignore_missing=True)
|
||||
assert results == []
|
||||
@@ -0,0 +1,267 @@
|
||||
import posixpath
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.databricks_volume import DatabricksVolumeAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.databricks_volume.path import backend_path
|
||||
from mirage.resource.databricks_volume import DatabricksVolumeConfig
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
status_code = 404
|
||||
|
||||
|
||||
class ToThreadRecorder:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
async def __call__(self, fn, *args, **kwargs):
|
||||
self.calls.append((fn, args, kwargs))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class FakeDownload:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.contents = BytesIO(data)
|
||||
|
||||
|
||||
class FakeFiles:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.downloads: dict[str, bytes] = {}
|
||||
self.metadata: dict[str, object] = {}
|
||||
self.directory_metadata: set[str] = set()
|
||||
self.directories: dict[str, list[object]] = {}
|
||||
self.metadata_errors: dict[str, Exception] = {}
|
||||
self.directory_metadata_errors: dict[str, Exception] = {}
|
||||
self.download_calls: list[str] = []
|
||||
self.get_metadata_calls: list[str] = []
|
||||
self.get_directory_metadata_calls: list[str] = []
|
||||
self.list_directory_calls: list[str] = []
|
||||
self.upload_calls: list[tuple[str, bytes, bool]] = []
|
||||
self.delete_calls: list[str] = []
|
||||
self.create_directory_calls: list[str] = []
|
||||
self.delete_directory_calls: list[str] = []
|
||||
|
||||
def download(self, path: str) -> FakeDownload:
|
||||
self.download_calls.append(path)
|
||||
if path not in self.downloads:
|
||||
raise NotFoundError(path)
|
||||
return FakeDownload(self.downloads[path])
|
||||
|
||||
def get_metadata(self, path: str) -> object:
|
||||
self.get_metadata_calls.append(path)
|
||||
if path in self.metadata_errors:
|
||||
raise self.metadata_errors[path]
|
||||
if path not in self.metadata:
|
||||
raise NotFoundError(path)
|
||||
return self.metadata[path]
|
||||
|
||||
def get_directory_metadata(self, path: str) -> None:
|
||||
self.get_directory_metadata_calls.append(path)
|
||||
if path in self.directory_metadata_errors:
|
||||
raise self.directory_metadata_errors[path]
|
||||
if path not in self.directory_metadata:
|
||||
raise NotFoundError(path)
|
||||
|
||||
def list_directory_contents(self, path: str) -> list[object]:
|
||||
self.list_directory_calls.append(path)
|
||||
if path not in self.directories:
|
||||
raise NotFoundError(path)
|
||||
return self.directories[path]
|
||||
|
||||
def create_directory(self, path: str) -> None:
|
||||
self.create_directory_calls.append(path)
|
||||
cur = ""
|
||||
for part in path.strip("/").split("/"):
|
||||
cur = cur + "/" + part
|
||||
if cur in self.directory_metadata:
|
||||
continue
|
||||
self.directory_metadata.add(cur)
|
||||
self.metadata[cur] = SimpleNamespace(is_directory=True)
|
||||
self.directories.setdefault(cur, [])
|
||||
parent = posixpath.dirname(cur) or "/"
|
||||
self._upsert_directory_entry(
|
||||
parent, SimpleNamespace(path=cur, is_directory=True))
|
||||
|
||||
def delete_directory(self, path: str) -> None:
|
||||
self.delete_directory_calls.append(path)
|
||||
if path not in self.directory_metadata:
|
||||
raise NotFoundError(path)
|
||||
if self.directories.get(path):
|
||||
raise OSError(f"directory not empty: {path}")
|
||||
self.directory_metadata.discard(path)
|
||||
self.metadata.pop(path, None)
|
||||
self.directories.pop(path, None)
|
||||
parent = posixpath.dirname(path.rstrip("/")) or "/"
|
||||
self.directories[parent] = [
|
||||
entry for entry in self.directories.get(parent, [])
|
||||
if getattr(entry, "path", None) != path
|
||||
]
|
||||
|
||||
def upload(self, path: str, contents, overwrite: bool = False) -> None:
|
||||
data = contents.read()
|
||||
self.upload_calls.append((path, data, overwrite))
|
||||
parent = posixpath.dirname(path.rstrip("/")) or "/"
|
||||
if parent not in self.directory_metadata:
|
||||
if parent in self.metadata:
|
||||
raise NotADirectoryError(parent)
|
||||
raise NotFoundError(parent)
|
||||
if path in self.directory_metadata:
|
||||
raise IsADirectoryError(path)
|
||||
self.downloads[path] = data
|
||||
self.metadata[path] = file_metadata(len(data))
|
||||
self._upsert_directory_entry(parent, file_entry(path, len(data)))
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
self.delete_calls.append(path)
|
||||
if path in self.directory_metadata:
|
||||
raise IsADirectoryError(path)
|
||||
if path not in self.metadata and path not in self.downloads:
|
||||
raise NotFoundError(path)
|
||||
self.metadata.pop(path, None)
|
||||
self.downloads.pop(path, None)
|
||||
parent = posixpath.dirname(path.rstrip("/")) or "/"
|
||||
self.directories[parent] = [
|
||||
entry for entry in self.directories.get(parent, [])
|
||||
if getattr(entry, "path", None) != path
|
||||
]
|
||||
|
||||
def _upsert_directory_entry(self, parent: str, entry: object) -> None:
|
||||
entries = [
|
||||
existing for existing in self.directories.get(parent, [])
|
||||
if getattr(existing, "path", None) != getattr(entry, "path", None)
|
||||
]
|
||||
entries.append(entry)
|
||||
self.directories[parent] = sorted(
|
||||
entries, key=lambda item: getattr(item, "path", ""))
|
||||
|
||||
|
||||
def _apply_range_header(data: bytes, range_header: str) -> bytes:
|
||||
if not range_header.startswith("bytes="):
|
||||
raise ValueError(f"unsupported range header: {range_header}")
|
||||
start_text, end_text = range_header.removeprefix("bytes=").split("-", 1)
|
||||
start = int(start_text) if start_text else 0
|
||||
end = int(end_text) + 1 if end_text else None
|
||||
return data[start:end]
|
||||
|
||||
|
||||
class FakeApiClient:
|
||||
|
||||
def __init__(self, files: FakeFiles) -> None:
|
||||
self.files = files
|
||||
self.do_calls: list[dict[str, object]] = []
|
||||
|
||||
def do(
|
||||
self,
|
||||
method: str,
|
||||
path: str | None = None,
|
||||
url: str | None = None,
|
||||
query: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
body: dict | None = None,
|
||||
raw: bool = False,
|
||||
files: object = None,
|
||||
data: object = None,
|
||||
auth: object = None,
|
||||
response_headers: list[str] | None = None,
|
||||
) -> dict:
|
||||
call = {
|
||||
"method": method,
|
||||
"path": path,
|
||||
"url": url,
|
||||
"query": query,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
"raw": raw,
|
||||
"files": files,
|
||||
"data": data,
|
||||
"auth": auth,
|
||||
"response_headers": response_headers,
|
||||
}
|
||||
self.do_calls.append(call)
|
||||
if method != "GET" or path is None:
|
||||
raise ValueError(f"unsupported fake API call: {method} {path}")
|
||||
remote_path = unquote(path.removeprefix("/api/2.0/fs/files"))
|
||||
if remote_path not in self.files.downloads:
|
||||
raise NotFoundError(remote_path)
|
||||
payload = self.files.downloads[remote_path]
|
||||
range_header = (headers or {}).get("Range")
|
||||
if range_header is not None:
|
||||
payload = _apply_range_header(payload, range_header)
|
||||
return {
|
||||
"contents": BytesIO(payload),
|
||||
"content-length": str(len(payload)),
|
||||
"accept-ranges": "bytes",
|
||||
}
|
||||
|
||||
|
||||
class FakeClient:
|
||||
|
||||
def __init__(self, files: FakeFiles) -> None:
|
||||
self.files = files
|
||||
self.api_client = FakeApiClient(files)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def databricks_config() -> DatabricksVolumeConfig:
|
||||
return DatabricksVolumeConfig(
|
||||
catalog="main",
|
||||
schema="default",
|
||||
volume="agent_files",
|
||||
root_path="/root",
|
||||
token="secret",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote_root(databricks_config: DatabricksVolumeConfig) -> str:
|
||||
return backend_path(databricks_config, "/")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files() -> FakeFiles:
|
||||
return FakeFiles()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(
|
||||
databricks_config: DatabricksVolumeConfig,
|
||||
files: FakeFiles,
|
||||
) -> DatabricksVolumeAccessor:
|
||||
return DatabricksVolumeAccessor(databricks_config, FakeClient(files))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index() -> RAMIndexCacheStore:
|
||||
return RAMIndexCacheStore(ttl=600)
|
||||
|
||||
|
||||
def file_metadata(size: int = 0, modified: str | None = None) -> object:
|
||||
return SimpleNamespace(
|
||||
content_length=size,
|
||||
content_type=None,
|
||||
last_modified=modified,
|
||||
)
|
||||
|
||||
|
||||
def directory_entry(path: str, modified: int | None = None) -> object:
|
||||
return SimpleNamespace(path=path,
|
||||
is_directory=True,
|
||||
file_size=None,
|
||||
last_modified=modified)
|
||||
|
||||
|
||||
def file_entry(path: str,
|
||||
size: int = 0,
|
||||
modified: int | None = None) -> object:
|
||||
return SimpleNamespace(path=path,
|
||||
is_directory=False,
|
||||
file_size=size,
|
||||
last_modified=modified)
|
||||
@@ -0,0 +1,148 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.copy import copy
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.directories.setdefault(path, [])
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
if parent and parent != path:
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=True, file_size=None))
|
||||
|
||||
|
||||
def _seed_file(files, path: str, data: bytes) -> None:
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
files.downloads[path] = data
|
||||
files.metadata[path] = SimpleNamespace(is_directory=False,
|
||||
file_size=len(data))
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=False, file_size=len(data)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_file_preserves_bytes(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/src.txt", b"hello")
|
||||
|
||||
await copy(accessor, _path("/dbx/src.txt"), _path("/dbx/dst.txt"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/dst.txt"] == b"hello"
|
||||
assert files.downloads[f"{remote_root}/src.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_missing_source_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await copy(accessor, _path("/dbx/missing.txt"), _path("/dbx/dst.txt"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_missing_parent_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/src.txt", b"hi")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await copy(accessor, _path("/dbx/src.txt"),
|
||||
_path("/dbx/missing/dst.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_directory_without_recursive_fails(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await copy(accessor, _path("/dbx/d"), _path("/dbx/d2"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_same_path_is_noop(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/src.txt", b"hello")
|
||||
|
||||
await copy(accessor, _path("/dbx/src.txt"), _path("/dbx/src.txt"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/src.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_same_missing_path_fails(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await copy(accessor, _path("/dbx/missing.txt"),
|
||||
_path("/dbx/missing.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_same_dir_without_recursive_fails(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await copy(accessor, _path("/dbx/d"), _path("/dbx/d"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_recursive_tree(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
_seed_file(files, f"{remote_root}/d/a.txt", b"aaa")
|
||||
|
||||
await copy(accessor,
|
||||
_path("/dbx/d"),
|
||||
_path("/dbx/d2"),
|
||||
index,
|
||||
recursive=True)
|
||||
|
||||
assert f"{remote_root}/d2" in files.directory_metadata
|
||||
assert files.downloads[f"{remote_root}/d2/a.txt"] == b"aaa"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_recursive_into_own_subtree_fails(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
_seed_file(files, f"{remote_root}/d/a.txt", b"aaa")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await copy(accessor,
|
||||
_path("/dbx/d"),
|
||||
_path("/dbx/d/d"),
|
||||
index,
|
||||
recursive=True)
|
||||
|
||||
assert files.create_directory_calls == []
|
||||
assert files.upload_calls == []
|
||||
assert files.downloads[f"{remote_root}/d/a.txt"] == b"aaa"
|
||||
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.exists import exists
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import file_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists_true_for_file(accessor, files, remote_root):
|
||||
files.metadata[f"{remote_root}/reports/latest.md"] = file_metadata(size=6)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
assert await exists(accessor, path) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists_false_for_missing_path(accessor):
|
||||
path = PathSpec.from_str_path("/volume/missing.md",
|
||||
mount_key("/volume/missing.md", "/volume"))
|
||||
assert await exists(accessor, path) is False
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.glob import resolve_glob
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import file_entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_file_path(accessor, index):
|
||||
scope = PathSpec.from_str_path("/volume/readme.md",
|
||||
mount_key("/volume/readme.md", "/volume"))
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
assert result == [scope]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_glob_pattern(accessor, files, index, remote_root):
|
||||
files.directories[f"{remote_root}/src"] = [
|
||||
file_entry(f"{remote_root}/src/main.py"),
|
||||
file_entry(f"{remote_root}/src/util.py"),
|
||||
file_entry(f"{remote_root}/src/data.json"),
|
||||
]
|
||||
scope = PathSpec(
|
||||
resource_path=mount_key("/volume/src/*.py", "/volume"),
|
||||
virtual="/volume/src/*.py",
|
||||
directory="/volume/src",
|
||||
pattern="*.py",
|
||||
resolved=False,
|
||||
)
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
originals = sorted(path.virtual for path in result)
|
||||
assert originals == ["/volume/src/main.py", "/volume/src/util.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_directory_path(accessor, index):
|
||||
scope = PathSpec(
|
||||
resource_path=mount_key("/volume/src", "/volume"),
|
||||
virtual="/volume/src",
|
||||
directory="/volume/src",
|
||||
resolved=False,
|
||||
)
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
assert result == [scope]
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.databricks_volume.head import head
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
async def collect_bytes(source) -> bytes:
|
||||
return b"".join([chunk async for chunk in source])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_bytes_mode_uses_single_small_range_request(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
source, _io = await head(accessor, [path], c="3")
|
||||
|
||||
assert await collect_bytes(source) == b"abc"
|
||||
assert files.download_calls == []
|
||||
assert len(accessor.client.api_client.do_calls) == 1
|
||||
assert accessor.client.api_client.do_calls[0]["headers"]["Range"] == (
|
||||
"bytes=0-2")
|
||||
@@ -0,0 +1,79 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.mkdir import mkdir
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.directories.setdefault(path, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_creates_directory(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
await mkdir(accessor, _path("/dbx/newdir"), index)
|
||||
|
||||
assert f"{remote_root}/newdir" in files.create_directory_calls
|
||||
assert f"{remote_root}/newdir" in files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_parent_missing_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await mkdir(accessor, _path("/dbx/a/b"), index)
|
||||
assert files.create_directory_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_parents_creates_chain(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
await mkdir(accessor, _path("/dbx/a/b/c"), index, parents=True)
|
||||
|
||||
assert f"{remote_root}/a/b/c" in files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_existing_target_fails(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/exists")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
await mkdir(accessor, _path("/dbx/exists"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_parent_is_file_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
files.metadata[f"{remote_root}/file.txt"] = SimpleNamespace(
|
||||
is_directory=False, file_size=3)
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await mkdir(accessor, _path("/dbx/file.txt/sub"), index)
|
||||
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mirage.core.databricks_volume.path import backend_path, virtual_path
|
||||
from mirage.resource.databricks_volume import DatabricksVolumeConfig
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def test_backend_path_uses_volume_root_and_strips_mount_prefix(
|
||||
databricks_config):
|
||||
path = PathSpec(
|
||||
resource_path=mount_key("/volume/reports/latest.md", "/volume"),
|
||||
virtual="/volume/reports/latest.md",
|
||||
directory="/volume/reports",
|
||||
)
|
||||
assert backend_path(
|
||||
databricks_config,
|
||||
path) == ("/Volumes/main/default/agent_files/root/reports/latest.md")
|
||||
|
||||
|
||||
def test_backend_path_allows_normalized_path_inside_root(databricks_config):
|
||||
path = PathSpec(
|
||||
resource_path=mount_key("/volume/reports/../latest.md", "/volume"),
|
||||
virtual="/volume/reports/../latest.md",
|
||||
directory="/volume/reports",
|
||||
)
|
||||
assert backend_path(
|
||||
databricks_config,
|
||||
path) == ("/Volumes/main/default/agent_files/root/latest.md")
|
||||
|
||||
|
||||
def test_backend_path_rejects_escape_above_configured_root(databricks_config):
|
||||
path = PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/volume/../../other_schema/other_volume/secret.txt", "/volume"),
|
||||
virtual="/volume/../../other_schema/other_volume/secret.txt",
|
||||
directory="/volume",
|
||||
)
|
||||
with pytest.raises(ValueError, match="escapes Databricks volume root"):
|
||||
backend_path(databricks_config, path)
|
||||
|
||||
|
||||
def test_config_rejects_parent_segments_in_root_path():
|
||||
with pytest.raises(ValidationError):
|
||||
DatabricksVolumeConfig(
|
||||
catalog="main",
|
||||
schema="default",
|
||||
volume="agent_files",
|
||||
root_path="/root/../other",
|
||||
)
|
||||
|
||||
|
||||
def test_virtual_path_rejects_backend_outside_root(databricks_config):
|
||||
with pytest.raises(ValueError, match="outside Databricks volume root"):
|
||||
virtual_path(
|
||||
databricks_config,
|
||||
"/Volumes/main/default/other_volume/secret.txt",
|
||||
"/volume",
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.read import read_bytes
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import ToThreadRecorder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file(accessor, files, remote_root):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"hello"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
result = await read_bytes(accessor, path)
|
||||
assert result == b"hello"
|
||||
assert files.download_calls == [f"{remote_root}/reports/latest.md"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_not_found(accessor):
|
||||
path = PathSpec.from_str_path("/volume/missing.md",
|
||||
mount_key("/volume/missing.md", "/volume"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read_bytes(accessor, path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_slice(accessor, files, remote_root):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
result = await read_bytes(accessor, path, offset=1, size=3)
|
||||
assert result == b"bcd"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_runs_blocking_download_off_event_loop(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
monkeypatch,
|
||||
):
|
||||
to_thread = ToThreadRecorder()
|
||||
monkeypatch.setattr(asyncio, "to_thread", to_thread)
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"hello"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await read_bytes(accessor, path)
|
||||
|
||||
assert result == b"hello"
|
||||
assert len(to_thread.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_slice_uses_databricks_range_request(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await read_bytes(accessor, path, offset=1, size=3)
|
||||
|
||||
assert result == b"bcd"
|
||||
assert files.download_calls == []
|
||||
assert accessor.client.api_client.do_calls[0]["headers"]["Range"] == (
|
||||
"bytes=1-3")
|
||||
assert accessor.client.api_client.do_calls[0]["raw"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_from_offset_uses_open_ended_range(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await read_bytes(accessor, path, offset=3)
|
||||
|
||||
assert result == b"def"
|
||||
assert files.download_calls == []
|
||||
assert accessor.client.api_client.do_calls[0]["headers"]["Range"] == (
|
||||
"bytes=3-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_zero_size_returns_empty_without_network(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await read_bytes(accessor, path, size=0)
|
||||
|
||||
assert result == b""
|
||||
assert files.download_calls == []
|
||||
assert accessor.client.api_client.do_calls == []
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.readdir import readdir
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import ToThreadRecorder, directory_entry, file_entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_returns_full_virtual_paths(
|
||||
accessor,
|
||||
files,
|
||||
index,
|
||||
remote_root,
|
||||
):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md", size=6),
|
||||
directory_entry(f"{remote_root}/reports/archive"),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
result = await readdir(accessor, path, index)
|
||||
assert result == [
|
||||
"/volume/reports/archive",
|
||||
"/volume/reports/latest.md",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_uses_cached_listing(accessor, files, index,
|
||||
remote_root):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md", size=6),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
assert await readdir(accessor, path,
|
||||
index) == ["/volume/reports/latest.md"]
|
||||
files.directories[f"{remote_root}/reports"] = []
|
||||
assert await readdir(accessor, path,
|
||||
index) == ["/volume/reports/latest.md"]
|
||||
assert files.list_directory_calls == [f"{remote_root}/reports"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_populates_index_with_size_and_modified(
|
||||
accessor,
|
||||
files,
|
||||
index,
|
||||
remote_root,
|
||||
):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md",
|
||||
size=6,
|
||||
modified=1_700_000_000_000),
|
||||
directory_entry(f"{remote_root}/reports/archive"),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
await readdir(accessor, path, index)
|
||||
file_lookup = await index.get("/volume/reports/latest.md")
|
||||
assert file_lookup.entry is not None
|
||||
assert file_lookup.entry.resource_type == "file"
|
||||
assert file_lookup.entry.size == 6
|
||||
assert file_lookup.entry.remote_time == "2023-11-14T22:13:20+00:00"
|
||||
dir_lookup = await index.get("/volume/reports/archive")
|
||||
assert dir_lookup.entry is not None
|
||||
assert dir_lookup.entry.resource_type == "folder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_missing_directory_raises(accessor, index):
|
||||
path = PathSpec.from_str_path("/volume/missing",
|
||||
mount_key("/volume/missing", "/volume"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(accessor, path, index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_runs_blocking_list_off_event_loop(
|
||||
accessor,
|
||||
files,
|
||||
index,
|
||||
remote_root,
|
||||
monkeypatch,
|
||||
):
|
||||
to_thread = ToThreadRecorder()
|
||||
monkeypatch.setattr(asyncio, "to_thread", to_thread)
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md", size=6),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
|
||||
result = await readdir(accessor, path, index)
|
||||
|
||||
assert result == ["/volume/reports/latest.md"]
|
||||
assert len(to_thread.calls) == 1
|
||||
@@ -0,0 +1,116 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.rename import rename
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.directories.setdefault(path, [])
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
if parent and parent != path:
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=True, file_size=None))
|
||||
|
||||
|
||||
def _seed_file(files, path: str, data: bytes) -> None:
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
files.downloads[path] = data
|
||||
files.metadata[path] = SimpleNamespace(is_directory=False,
|
||||
file_size=len(data))
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=False, file_size=len(data)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_file_moves_bytes(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/src.txt", b"data")
|
||||
|
||||
await rename(accessor, _path("/dbx/src.txt"), _path("/dbx/dst.txt"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/dst.txt"] == b"data"
|
||||
assert f"{remote_root}/src.txt" not in files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_missing_source_fails(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await rename(accessor, _path("/dbx/missing.txt"),
|
||||
_path("/dbx/dst.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_same_path_is_noop(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/src.txt", b"data")
|
||||
|
||||
await rename(accessor, _path("/dbx/src.txt"), _path("/dbx/src.txt"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/src.txt"] == b"data"
|
||||
assert f"{remote_root}/src.txt" not in files.delete_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_same_missing_path_fails(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await rename(accessor, _path("/dbx/missing.txt"),
|
||||
_path("/dbx/missing.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_directory_moves_tree(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
_seed_file(files, f"{remote_root}/d/a.txt", b"aaa")
|
||||
|
||||
await rename(accessor, _path("/dbx/d"), _path("/dbx/d2"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/d2/a.txt"] == b"aaa"
|
||||
assert f"{remote_root}/d" not in files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_into_own_subtree_fails(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
_seed_file(files, f"{remote_root}/d/a.txt", b"aaa")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await rename(accessor, _path("/dbx/d"), _path("/dbx/d/d"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/d/a.txt"] == b"aaa"
|
||||
assert f"{remote_root}/d" in files.directory_metadata
|
||||
assert files.create_directory_calls == []
|
||||
assert files.upload_calls == []
|
||||
assert files.delete_calls == []
|
||||
assert files.delete_directory_calls == []
|
||||
@@ -0,0 +1,81 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.rm import rm_recursive
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.directories.setdefault(path, [])
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
if parent and parent != path:
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=True, file_size=None))
|
||||
|
||||
|
||||
def _seed_file(files, path: str, data: bytes = b"x") -> None:
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
files.downloads[path] = data
|
||||
files.metadata[path] = SimpleNamespace(is_directory=False,
|
||||
file_size=len(data))
|
||||
files.directories.setdefault(parent, []).append(
|
||||
SimpleNamespace(path=path, is_directory=False, file_size=len(data)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_file(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/a.txt")
|
||||
|
||||
removed = await rm_recursive(accessor, _path("/dbx/a.txt"), index)
|
||||
|
||||
assert removed == ["/a.txt"]
|
||||
assert f"{remote_root}/a.txt" not in files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_nested_tree(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/d")
|
||||
_seed_file(files, f"{remote_root}/d/a.txt")
|
||||
_seed_directory(files, f"{remote_root}/d/sub")
|
||||
_seed_file(files, f"{remote_root}/d/sub/b.txt")
|
||||
|
||||
removed = await rm_recursive(accessor, _path("/dbx/d"), index)
|
||||
|
||||
assert f"{remote_root}/d/a.txt" in files.delete_calls
|
||||
assert f"{remote_root}/d/sub/b.txt" in files.delete_calls
|
||||
assert f"{remote_root}/d/sub" in files.delete_directory_calls
|
||||
assert f"{remote_root}/d" in files.delete_directory_calls
|
||||
assert "/d" in removed
|
||||
assert f"{remote_root}/d" not in files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_missing_raises(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await rm_recursive(accessor, _path("/dbx/missing"), index)
|
||||
@@ -0,0 +1,79 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.rmdir import rmdir
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.directories.setdefault(path, [])
|
||||
|
||||
|
||||
def _seed_file(files, path: str, data: bytes = b"x") -> None:
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
files.downloads[path] = data
|
||||
files.metadata[path] = SimpleNamespace(is_directory=False,
|
||||
file_size=len(data))
|
||||
files.directories.setdefault(parent, [])
|
||||
files.directories[parent].append(
|
||||
SimpleNamespace(path=path, is_directory=False, file_size=len(data)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rmdir_empty_succeeds(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/empty")
|
||||
|
||||
await rmdir(accessor, _path("/dbx/empty"), index)
|
||||
|
||||
assert files.delete_directory_calls == [f"{remote_root}/empty"]
|
||||
assert f"{remote_root}/empty" not in files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rmdir_non_empty_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/full")
|
||||
_seed_file(files, f"{remote_root}/full/a.txt")
|
||||
|
||||
with pytest.raises(OSError):
|
||||
await rmdir(accessor, _path("/dbx/full"), index)
|
||||
assert files.delete_directory_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rmdir_file_target_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/a.txt")
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await rmdir(accessor, _path("/dbx/a.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rmdir_missing_fails(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await rmdir(accessor, _path("/dbx/missing"), index)
|
||||
@@ -0,0 +1,311 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.databricks_volume.readdir import readdir
|
||||
from mirage.core.databricks_volume.stat import (_name_from_backend_path,
|
||||
modified_to_iso, stat)
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import (ToThreadRecorder, directory_entry, file_entry,
|
||||
file_metadata)
|
||||
|
||||
|
||||
def test_modified_none_and_empty_string_return_none():
|
||||
assert modified_to_iso(None) is None
|
||||
assert modified_to_iso("") is None
|
||||
|
||||
|
||||
def test_modified_parses_http_date_to_iso_utc():
|
||||
assert modified_to_iso(
|
||||
"Tue, 14 Nov 2023 22:13:20 GMT") == "2023-11-14T22:13:20+00:00"
|
||||
|
||||
|
||||
def test_modified_returns_unparseable_string_verbatim():
|
||||
assert modified_to_iso("not a date") == "not a date"
|
||||
|
||||
|
||||
def test_modified_coerces_naive_datetime_to_utc():
|
||||
naive = datetime(2023, 11, 14, 22, 13, 20)
|
||||
assert modified_to_iso(naive) == "2023-11-14T22:13:20+00:00"
|
||||
|
||||
|
||||
def test_modified_converts_aware_datetime_to_utc():
|
||||
aware = datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)
|
||||
assert modified_to_iso(aware) == "2023-11-14T22:13:20+00:00"
|
||||
|
||||
|
||||
def test_modified_treats_large_int_as_epoch_milliseconds():
|
||||
assert modified_to_iso(1_700_000_000_000) == "2023-11-14T22:13:20+00:00"
|
||||
|
||||
|
||||
def test_name_from_backend_path_file():
|
||||
assert _name_from_backend_path(
|
||||
"/Volumes/main/default/agent_files/root/latest.md") == "latest.md"
|
||||
|
||||
|
||||
def test_name_from_backend_path_directory_with_trailing_slash():
|
||||
assert _name_from_backend_path(
|
||||
"/Volumes/main/default/agent_files/root/reports/") == "reports"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file(accessor, files, remote_root):
|
||||
files.metadata[f"{remote_root}/reports/latest.md"] = file_metadata(
|
||||
size=6,
|
||||
modified="Tue, 14 Nov 2023 22:13:20 GMT",
|
||||
)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
result = await stat(accessor, path)
|
||||
assert result.name == "latest.md"
|
||||
assert result.size == 6
|
||||
assert result.modified == "2023-11-14T22:13:20+00:00"
|
||||
assert result.type != FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file_from_index_skips_sdk(accessor, files, index,
|
||||
remote_root):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md",
|
||||
size=6,
|
||||
modified=1_700_000_000_000),
|
||||
]
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume")), index)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
result = await stat(accessor, path, index)
|
||||
assert result.name == "latest.md"
|
||||
assert result.size == 6
|
||||
assert result.modified == "2023-11-14T22:13:20+00:00"
|
||||
assert result.type != FileType.DIRECTORY
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory_from_index_skips_sdk(accessor, files, index,
|
||||
remote_root):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
directory_entry(f"{remote_root}/reports/archive"),
|
||||
]
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume")), index)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/archive",
|
||||
mount_key("/volume/reports/archive", "/volume"))
|
||||
result = await stat(accessor, path, index)
|
||||
assert result.name == "archive"
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_index_negative_cache_raises_without_sdk(
|
||||
accessor,
|
||||
files,
|
||||
index,
|
||||
remote_root,
|
||||
):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md", size=6),
|
||||
]
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume")), index)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/missing.md",
|
||||
mount_key("/volume/reports/missing.md", "/volume"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, path, index)
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_index_fast_path_matches_sdk(accessor, files, index,
|
||||
remote_root):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
file_entry(f"{remote_root}/reports/latest.md",
|
||||
size=6,
|
||||
modified=1_700_000_000_000),
|
||||
]
|
||||
files.metadata[f"{remote_root}/reports/latest.md"] = file_metadata(
|
||||
size=6,
|
||||
modified="Tue, 14 Nov 2023 22:13:20 GMT",
|
||||
)
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume")), index)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
fast = await stat(accessor, path, index)
|
||||
slow = await stat(accessor, path, RAMIndexCacheStore(ttl=600))
|
||||
assert fast == slow
|
||||
assert files.get_metadata_calls == [f"{remote_root}/reports/latest.md"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory_index_fast_path_matches_sdk(
|
||||
accessor,
|
||||
files,
|
||||
index,
|
||||
remote_root,
|
||||
):
|
||||
files.directories[f"{remote_root}/reports"] = [
|
||||
directory_entry(f"{remote_root}/reports/archive"),
|
||||
]
|
||||
files.directory_metadata.add(f"{remote_root}/reports/archive")
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume")), index)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/archive",
|
||||
mount_key("/volume/reports/archive", "/volume"))
|
||||
fast = await stat(accessor, path, index)
|
||||
slow = await stat(accessor, path, RAMIndexCacheStore(ttl=600))
|
||||
assert fast == slow
|
||||
assert files.get_directory_metadata_calls == [
|
||||
f"{remote_root}/reports/archive"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root_does_not_call_sdk(accessor, files):
|
||||
path = PathSpec.from_str_path("/volume", mount_key("/volume", "/volume"))
|
||||
result = await stat(accessor, path)
|
||||
assert result.name == "/"
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory_uses_directory_metadata_fallback(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.directory_metadata.add(f"{remote_root}/reports")
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
result = await stat(accessor, path)
|
||||
assert result.name == "reports"
|
||||
assert result.size is None
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert files.get_metadata_calls == [f"{remote_root}/reports"]
|
||||
assert files.get_directory_metadata_calls == [f"{remote_root}/reports"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_missing_path_raises(accessor):
|
||||
path = PathSpec.from_str_path("/volume/missing",
|
||||
mount_key("/volume/missing", "/volume"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_missing_path_checks_directory_metadata(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
path = PathSpec.from_str_path("/volume/missing",
|
||||
mount_key("/volume/missing", "/volume"))
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, path)
|
||||
assert files.get_metadata_calls == [f"{remote_root}/missing"]
|
||||
assert files.get_directory_metadata_calls == [f"{remote_root}/missing"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_rejects_path_escape(accessor):
|
||||
path = PathSpec.from_str_path("/volume/../outside",
|
||||
mount_key("/volume/../outside", "/volume"))
|
||||
with pytest.raises(ValueError, match="escapes Databricks volume root"):
|
||||
await stat(accessor, path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_metadata_error_propagates(accessor, files, remote_root):
|
||||
remote_path = f"{remote_root}/reports"
|
||||
files.metadata_errors[remote_path] = RuntimeError("metadata failed")
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
with pytest.raises(RuntimeError, match="metadata failed"):
|
||||
await stat(accessor, path)
|
||||
assert files.get_metadata_calls == [remote_path]
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory_metadata_error_propagates(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
remote_path = f"{remote_root}/reports"
|
||||
files.directory_metadata_errors[remote_path] = RuntimeError(
|
||||
"directory metadata failed")
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
with pytest.raises(RuntimeError, match="directory metadata failed"):
|
||||
await stat(accessor, path)
|
||||
assert files.get_metadata_calls == [remote_path]
|
||||
assert files.get_directory_metadata_calls == [remote_path]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_runs_blocking_metadata_off_event_loop(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
monkeypatch,
|
||||
):
|
||||
to_thread = ToThreadRecorder()
|
||||
monkeypatch.setattr(asyncio, "to_thread", to_thread)
|
||||
files.metadata[f"{remote_root}/reports/latest.md"] = file_metadata(size=6)
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await stat(accessor, path)
|
||||
|
||||
assert result.name == "latest.md"
|
||||
assert len(to_thread.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory_fallback_runs_off_event_loop(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
monkeypatch,
|
||||
):
|
||||
to_thread = ToThreadRecorder()
|
||||
monkeypatch.setattr(asyncio, "to_thread", to_thread)
|
||||
files.directory_metadata.add(f"{remote_root}/reports")
|
||||
path = PathSpec.from_str_path("/volume/reports",
|
||||
mount_key("/volume/reports", "/volume"))
|
||||
|
||||
result = await stat(accessor, path)
|
||||
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert len(to_thread.calls) == 2
|
||||
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.stream import range_read, read_stream
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
class TrackingContents:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.data = data
|
||||
self.offset = 0
|
||||
self.read_sizes: list[int] = []
|
||||
self.closed = False
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
self.read_sizes.append(size)
|
||||
if size < 0:
|
||||
size = len(self.data) - self.offset
|
||||
chunk = self.data[self.offset:self.offset + size]
|
||||
self.offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class TrackingDownload:
|
||||
|
||||
def __init__(self, contents: TrackingContents) -> None:
|
||||
self.contents = contents
|
||||
|
||||
|
||||
class TrackingFiles:
|
||||
|
||||
def __init__(self, contents: TrackingContents) -> None:
|
||||
self.contents = contents
|
||||
self.download_calls: list[str] = []
|
||||
|
||||
def download(self, path: str) -> TrackingDownload:
|
||||
self.download_calls.append(path)
|
||||
return TrackingDownload(self.contents)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_chunks_file(accessor, files, remote_root):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
chunks = [
|
||||
chunk async for chunk in read_stream(accessor, path, chunk_size=2)
|
||||
]
|
||||
assert chunks == [b"ab", b"cd", b"ef"]
|
||||
# Streaming should use one download body, not one Range GET per chunk.
|
||||
assert files.download_calls == [f"{remote_root}/reports/latest.md"]
|
||||
assert accessor.client.api_client.do_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_range_read_uses_end_exclusive(accessor, files, remote_root):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
result = await range_read(accessor, path, 1, 4)
|
||||
assert result == b"bcd"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_range_read_uses_single_databricks_range_request(
|
||||
accessor,
|
||||
files,
|
||||
remote_root,
|
||||
):
|
||||
files.downloads[f"{remote_root}/reports/latest.md"] = b"abcdef"
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
|
||||
result = await range_read(accessor, path, 1, 4)
|
||||
|
||||
assert result == b"bcd"
|
||||
assert files.download_calls == []
|
||||
assert len(accessor.client.api_client.do_calls) == 1
|
||||
assert accessor.client.api_client.do_calls[0]["headers"]["Range"] == (
|
||||
"bytes=1-3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_reads_single_download_body_in_chunks(
|
||||
accessor,
|
||||
remote_root,
|
||||
):
|
||||
contents = TrackingContents(b"abcdef")
|
||||
tracking_files = TrackingFiles(contents)
|
||||
accessor.client.files = tracking_files
|
||||
path = PathSpec.from_str_path(
|
||||
"/volume/reports/latest.md",
|
||||
mount_key("/volume/reports/latest.md", "/volume"))
|
||||
stream = read_stream(accessor, path, chunk_size=2)
|
||||
|
||||
first = await anext(stream)
|
||||
second = await anext(stream)
|
||||
|
||||
assert first == b"ab"
|
||||
assert second == b"cd"
|
||||
assert tracking_files.download_calls == [
|
||||
f"{remote_root}/reports/latest.md"
|
||||
]
|
||||
assert contents.read_sizes == [2, 2]
|
||||
assert not contents.closed
|
||||
await stream.aclose()
|
||||
assert contents.closed
|
||||
@@ -0,0 +1,158 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.databricks_volume.create import create
|
||||
from mirage.core.databricks_volume.unlink import unlink
|
||||
from mirage.core.databricks_volume.write import write_bytes
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec.from_str_path(path, mount_key(path, "/dbx"))
|
||||
|
||||
|
||||
def _seed_directory(files, path: str) -> None:
|
||||
files.directory_metadata.add(path)
|
||||
files.metadata[path] = type("Metadata", (), {"is_directory": True})()
|
||||
files.directories.setdefault(path, [])
|
||||
|
||||
|
||||
def _seed_file(files, path: str, data: bytes) -> None:
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
files.downloads[path] = data
|
||||
files.metadata[path] = type(
|
||||
"Metadata",
|
||||
(),
|
||||
{
|
||||
"content_length": len(data),
|
||||
},
|
||||
)()
|
||||
files.directories.setdefault(parent, [])
|
||||
files.directories[parent].append(
|
||||
type(
|
||||
"Entry",
|
||||
(),
|
||||
{
|
||||
"path": path,
|
||||
"is_directory": False,
|
||||
"file_size": len(data),
|
||||
},
|
||||
)())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_new_file(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
await write_bytes(accessor, _path("/dbx/new.txt"), b"hello", index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/new.txt"] == b"hello"
|
||||
assert files.upload_calls == [(f"{remote_root}/new.txt", b"hello", True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_overwrites_existing_file(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/new.txt", b"old")
|
||||
|
||||
await write_bytes(accessor, _path("/dbx/new.txt"), b"new", index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/new.txt"] == b"new"
|
||||
assert files.metadata[f"{remote_root}/new.txt"].content_length == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_fails_when_parent_is_missing(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await write_bytes(accessor, _path("/dbx/missing/new.txt"), b"x", index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_fails_when_parent_is_file(accessor, files, remote_root,
|
||||
index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/parent.txt", b"file")
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await write_bytes(accessor, _path("/dbx/parent.txt/new.txt"), b"x",
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_empty_file(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
await create(accessor, _path("/dbx/empty.txt"), index)
|
||||
|
||||
assert files.downloads[f"{remote_root}/empty.txt"] == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_fails_when_parent_is_missing(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await create(accessor, _path("/dbx/missing/empty.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_file(accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_file(files, f"{remote_root}/delete.txt", b"bye")
|
||||
|
||||
await unlink(accessor, _path("/dbx/delete.txt"), index)
|
||||
|
||||
assert f"{remote_root}/delete.txt" not in files.downloads
|
||||
assert files.delete_calls == [f"{remote_root}/delete.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_missing_file_raises_file_not_found(
|
||||
accessor, files, remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await unlink(accessor, _path("/dbx/missing.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_directory_raises_is_a_directory(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
_seed_directory(files, f"{remote_root}/dir")
|
||||
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await unlink(accessor, _path("/dbx/dir"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_mutation_paths_cannot_escape_root(accessor, files,
|
||||
remote_root, index):
|
||||
_seed_directory(files, remote_root)
|
||||
escaping = _path("/dbx/../escape.txt")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await write_bytes(accessor, escaping, b"x", index)
|
||||
with pytest.raises(ValueError):
|
||||
await create(accessor, escaping, index)
|
||||
with pytest.raises(ValueError):
|
||||
await unlink(accessor, escaping, index)
|
||||
@@ -0,0 +1,97 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def document(
|
||||
document_id: str,
|
||||
name: str,
|
||||
*,
|
||||
slug: object | None = None,
|
||||
enabled: bool = True,
|
||||
indexing_status: str = "completed",
|
||||
archived: bool = False,
|
||||
size: int | None = 123,
|
||||
) -> dict:
|
||||
doc_metadata = []
|
||||
if slug is not None:
|
||||
doc_metadata = [{"name": "slug", "value": slug}]
|
||||
data_source_detail_dict = {}
|
||||
if size is not None:
|
||||
data_source_detail_dict = {"upload_file": {"size": size}}
|
||||
return {
|
||||
"id": document_id,
|
||||
"name": name,
|
||||
"doc_metadata": doc_metadata,
|
||||
"enabled": enabled,
|
||||
"indexing_status": indexing_status,
|
||||
"archived": archived,
|
||||
"tokens": 9,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": data_source_detail_dict,
|
||||
"created_at": 1716282000,
|
||||
}
|
||||
|
||||
|
||||
def file_entry(document_id: str, name: str, size: int = 123) -> IndexEntry:
|
||||
return IndexEntry(id=document_id,
|
||||
name=name,
|
||||
resource_type="file",
|
||||
size=size)
|
||||
|
||||
|
||||
def folder_entry(name: str) -> IndexEntry:
|
||||
return IndexEntry(id=name, name=name, resource_type="folder")
|
||||
|
||||
|
||||
async def no_documents(config):
|
||||
return []
|
||||
|
||||
|
||||
async def list_basic_documents(config):
|
||||
return [
|
||||
document("doc-1", "Quickstart", slug="guides/quickstart", size=333),
|
||||
document("doc-2", "README.md", size=None),
|
||||
]
|
||||
|
||||
|
||||
async def list_nested_documents(config):
|
||||
return [
|
||||
document("doc-1", "Quickstart", slug="guides/quickstart", size=333),
|
||||
document("doc-2", "Notes", slug="guides/deep/note", size=20),
|
||||
document("doc-3", "README.md", size=10),
|
||||
]
|
||||
|
||||
|
||||
async def noop_ensure_tree(accessor, index, prefix=""):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dify_accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(dataset_id="dataset-1",
|
||||
slug_metadata_name="slug"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dify_index() -> RAMIndexCacheStore:
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def knowledge_root() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/knowledge", "/knowledge"),
|
||||
virtual="/knowledge",
|
||||
directory="/knowledge")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guide_path() -> PathSpec:
|
||||
return PathSpec.from_str_path(
|
||||
"/knowledge/guides/quickstart",
|
||||
mount_key("/knowledge/guides/quickstart", "/knowledge"))
|
||||
@@ -0,0 +1,147 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import _client
|
||||
from mirage.resource.dify.config import DifyConfig
|
||||
|
||||
|
||||
def config() -> DifyConfig:
|
||||
return DifyConfig(
|
||||
api_key="secret",
|
||||
base_url="https://dify.example/v1/",
|
||||
dataset_id="dataset-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_documents_paginates_and_filters(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"id": "doc-1",
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": 10
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "doc-2",
|
||||
"enabled": False,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
},
|
||||
],
|
||||
"has_more":
|
||||
True,
|
||||
})
|
||||
httpx_mock.add_response(
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"id": "doc-3",
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
},
|
||||
{
|
||||
"id": "doc-4",
|
||||
"enabled": True,
|
||||
"indexing_status": "indexing",
|
||||
"archived": False,
|
||||
},
|
||||
],
|
||||
"has_more":
|
||||
False,
|
||||
})
|
||||
|
||||
documents = await _client.list_all_documents(config())
|
||||
|
||||
assert [item["id"] for item in documents] == ["doc-1", "doc-3"]
|
||||
assert documents[0]["data_source_detail_dict"]["upload_file"]["size"] == 10
|
||||
|
||||
requests = httpx_mock.get_requests()
|
||||
assert len(requests) == 2
|
||||
assert requests[0].headers["authorization"] == "Bearer secret"
|
||||
assert requests[0].url.params["page"] == "1"
|
||||
assert requests[0].url.params["limit"] == "100"
|
||||
assert requests[1].url.params["page"] == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dify_get_retries_rate_limit(monkeypatch, httpx_mock):
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(_client.asyncio, "sleep", sleep)
|
||||
httpx_mock.add_response(status_code=429, json={"message": "rate limit"})
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
|
||||
payload = await _client.dify_get(config(), "/datasets/dataset-1/documents")
|
||||
|
||||
assert payload == {"ok": True}
|
||||
assert sleeps == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dify_get_raises_http_status_errors(httpx_mock):
|
||||
httpx_mock.add_response(status_code=401, json={"message": "unauthorized"})
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await _client.dify_get(config(), "/datasets/dataset-1/documents")
|
||||
|
||||
assert exc_info.value.response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dify_post_sends_json_and_retries_server_error(
|
||||
monkeypatch, httpx_mock):
|
||||
sleeps: list[int] = []
|
||||
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(_client.asyncio, "sleep", sleep)
|
||||
httpx_mock.add_response(status_code=500, json={"message": "temporary"})
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
|
||||
payload = await _client.dify_post(config(), "/datasets/dataset-1/retrieve",
|
||||
{"query": "hello"})
|
||||
|
||||
assert payload == {"ok": True}
|
||||
assert sleeps == [1]
|
||||
requests = httpx_mock.get_requests()
|
||||
assert len(requests) == 2
|
||||
assert requests[0].headers["authorization"] == "Bearer secret"
|
||||
assert requests[0].read() == b'{"query":"hello"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_segments_paginates_with_server_filters(httpx_mock):
|
||||
httpx_mock.add_response(json={
|
||||
"data": [{
|
||||
"content": "first"
|
||||
}],
|
||||
"has_more": True,
|
||||
})
|
||||
httpx_mock.add_response(json={
|
||||
"data": [{
|
||||
"content": "second"
|
||||
}],
|
||||
"has_more": False,
|
||||
})
|
||||
|
||||
segments = await _client.get_document_segments(config(), "doc-1")
|
||||
|
||||
assert [item["content"] for item in segments] == ["first", "second"]
|
||||
requests = httpx_mock.get_requests()
|
||||
assert requests[0].url.params["status"] == "completed"
|
||||
assert requests[0].url.params["enabled"] == "true"
|
||||
assert requests[0].url.params["page"] == "1"
|
||||
assert requests[1].url.params["page"] == "2"
|
||||
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import find, stat, tree
|
||||
from mirage.types import FindType
|
||||
|
||||
from .conftest import list_nested_documents
|
||||
|
||||
|
||||
async def get_detail(config, document_id):
|
||||
sizes = {
|
||||
"doc-1": 333,
|
||||
"doc-2": 20,
|
||||
"doc-3": 10,
|
||||
}
|
||||
return {
|
||||
"id": document_id,
|
||||
"updated_at": 1716285600,
|
||||
"data_source_info": {
|
||||
"upload_file": {
|
||||
"size": sizes[document_id]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_name_matches_mount_root_start_path(monkeypatch,
|
||||
dify_accessor,
|
||||
dify_index,
|
||||
knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
monkeypatch.setattr(stat, "get_document_detail", get_detail)
|
||||
results = await find.find(dify_accessor,
|
||||
knowledge_root,
|
||||
name="knowledge",
|
||||
index=dify_index)
|
||||
assert results == ["/"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_filters_name_type_size_and_depth(monkeypatch,
|
||||
dify_accessor, dify_index,
|
||||
knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
monkeypatch.setattr(stat, "get_document_detail", get_detail)
|
||||
|
||||
named = await find.find(dify_accessor,
|
||||
knowledge_root,
|
||||
name="quick*",
|
||||
index=dify_index)
|
||||
assert named == ["/guides/quickstart"]
|
||||
|
||||
directories = await find.find(dify_accessor,
|
||||
knowledge_root,
|
||||
type=FindType.DIRECTORY,
|
||||
index=dify_index)
|
||||
assert directories == ["/", "/guides", "/guides/deep"]
|
||||
|
||||
large_files = await find.find(dify_accessor,
|
||||
knowledge_root,
|
||||
type=FindType.FILE,
|
||||
min_size=100,
|
||||
index=dify_index)
|
||||
assert large_files == ["/guides/quickstart"]
|
||||
|
||||
deep = await find.find(dify_accessor,
|
||||
knowledge_root,
|
||||
mindepth=2,
|
||||
index=dify_index)
|
||||
assert "/" not in deep
|
||||
assert "/guides" not in deep
|
||||
assert "/guides/deep" in deep
|
||||
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import glob, tree
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import list_nested_documents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_glob_keeps_unresolved_non_pattern_path(
|
||||
monkeypatch, dify_accessor, dify_index):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
path = PathSpec(resource_path=mount_key("/knowledge/guides", "/knowledge"),
|
||||
virtual="/knowledge/guides",
|
||||
directory="/knowledge/guides",
|
||||
resolved=False)
|
||||
|
||||
matches = await glob.resolve_glob(dify_accessor, [path], dify_index)
|
||||
|
||||
assert matches == [path]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_glob_matches_directory_pattern(monkeypatch,
|
||||
dify_accessor,
|
||||
dify_index):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
path = PathSpec(resource_path=mount_key("/knowledge/guides/quick*",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/quick*",
|
||||
directory="/knowledge/guides",
|
||||
pattern="quick*",
|
||||
resolved=False)
|
||||
|
||||
matches = await glob.resolve_glob(dify_accessor, [path], dify_index)
|
||||
|
||||
assert [item.virtual
|
||||
for item in matches] == ["/knowledge/guides/quickstart"]
|
||||
assert matches[0].directory == "/knowledge/guides/"
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import grep, read, tree
|
||||
|
||||
from .conftest import list_basic_documents
|
||||
|
||||
|
||||
async def iter_pages(config, document_id):
|
||||
yield [{"content": "Alpha"}]
|
||||
yield [{"content": "beta"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_bytes_matches_streamed_lines_and_records_reads(
|
||||
monkeypatch, dify_accessor, dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
monkeypatch.setattr(read, "iter_segment_pages", iter_pages)
|
||||
|
||||
output, reads = await grep.grep_bytes(dify_accessor, [guide_path],
|
||||
"alpha",
|
||||
dify_index,
|
||||
ignore_case=True)
|
||||
|
||||
assert output == b"/knowledge/guides/quickstart:1:Alpha"
|
||||
assert reads == {guide_path.virtual: b"Alpha\nbeta"}
|
||||
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.core.dify import path
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import folder_entry, noop_ensure_tree
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_path_finds_files_and_directories(
|
||||
monkeypatch, dify_accessor, dify_index):
|
||||
monkeypatch.setattr(path, "ensure_tree", noop_ensure_tree)
|
||||
await dify_index.set_dir(
|
||||
"/knowledge",
|
||||
[
|
||||
("guides", folder_entry("guides")),
|
||||
("README.md",
|
||||
IndexEntry(id="doc-1", name="README.md", resource_type="file")),
|
||||
],
|
||||
)
|
||||
await dify_index.set_dir("/knowledge/guides", [])
|
||||
|
||||
file_result = await path.resolve_path(
|
||||
dify_accessor,
|
||||
PathSpec.from_str_path("/knowledge/README.md",
|
||||
mount_key("/knowledge/README.md",
|
||||
"/knowledge")),
|
||||
dify_index,
|
||||
)
|
||||
dir_result = await path.resolve_path(
|
||||
dify_accessor,
|
||||
PathSpec.from_str_path("/knowledge/guides",
|
||||
mount_key("/knowledge/guides", "/knowledge")),
|
||||
dify_index,
|
||||
)
|
||||
|
||||
assert file_result.virtual_key == "/knowledge/README.md"
|
||||
assert file_result.is_dir is False
|
||||
assert file_result.entry is not None
|
||||
assert dir_result.virtual_key == "/knowledge/guides"
|
||||
assert dir_result.is_dir is True
|
||||
assert dir_result.entry is not None
|
||||
assert dir_result.entry.resource_type == "folder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_path_raises_missing(monkeypatch, dify_accessor,
|
||||
dify_index):
|
||||
monkeypatch.setattr(path, "ensure_tree", noop_ensure_tree)
|
||||
await dify_index.set_dir("/knowledge", [])
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await path.resolve_path(
|
||||
dify_accessor,
|
||||
PathSpec.from_str_path(
|
||||
"/knowledge/missing",
|
||||
mount_key("/knowledge/missing", "/knowledge")),
|
||||
dify_index,
|
||||
)
|
||||
|
||||
|
||||
def test_virtual_key_for_honors_prefix_and_patterns():
|
||||
assert path.virtual_key_for(
|
||||
PathSpec.from_str_path(
|
||||
"/knowledge/guides/quickstart",
|
||||
"guides/quickstart")) == "/knowledge/guides/quickstart"
|
||||
assert path.virtual_key_for(
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides/*.md",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/*.md",
|
||||
directory="/knowledge/guides",
|
||||
pattern="*.md")) == "/knowledge/guides"
|
||||
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import read, tree
|
||||
|
||||
from .conftest import list_basic_documents
|
||||
|
||||
|
||||
async def get_segments(config, document_id):
|
||||
return [{"content": "first"}, {"content": "second"}]
|
||||
|
||||
|
||||
async def iter_pages(config, document_id):
|
||||
yield [{"content": "first"}]
|
||||
yield [{"content": "second"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_uses_document_segments(monkeypatch, dify_accessor,
|
||||
dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
monkeypatch.setattr(read, "get_document_segments", get_segments)
|
||||
|
||||
data = await read.read_bytes(dify_accessor, guide_path, dify_index)
|
||||
|
||||
assert data == b"first\nsecond"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_separates_pages_with_single_newline(
|
||||
monkeypatch, dify_accessor, dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
monkeypatch.setattr(read, "iter_segment_pages", iter_pages)
|
||||
|
||||
chunks = [
|
||||
chunk async for chunk in read.read_stream(dify_accessor, guide_path,
|
||||
dify_index)
|
||||
]
|
||||
|
||||
assert chunks == [b"first", b"\n", b"second"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_rejects_directories(monkeypatch, dify_accessor,
|
||||
dify_index, knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await read.read_bytes(dify_accessor, knowledge_root, dify_index)
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import readdir, tree
|
||||
|
||||
from .conftest import list_basic_documents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_returns_directory_children(monkeypatch, dify_accessor,
|
||||
dify_index, knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
|
||||
children = await readdir.readdir(dify_accessor, knowledge_root, dify_index)
|
||||
|
||||
assert children == ["/knowledge/README.md", "/knowledge/guides"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_rejects_files(monkeypatch, dify_accessor, dify_index,
|
||||
guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await readdir.readdir(dify_accessor, guide_path, dify_index)
|
||||
@@ -0,0 +1,428 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def document(document_id: str, name: str, slug: str | None = None) -> dict:
|
||||
metadata = []
|
||||
if slug is not None:
|
||||
metadata = [{"name": "slug", "value": slug}]
|
||||
return {
|
||||
"id": document_id,
|
||||
"name": name,
|
||||
"doc_metadata": metadata,
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"tokens": 4,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": 12
|
||||
}
|
||||
},
|
||||
"created_at": 1716282000,
|
||||
}
|
||||
|
||||
|
||||
def accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(dataset_id="dataset-1",
|
||||
slug_metadata_name="slug"))
|
||||
|
||||
|
||||
def custom_slug_accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(dataset_id="dataset-1",
|
||||
slug_metadata_name="path"))
|
||||
|
||||
|
||||
custom_slug_search_bodies: list[dict] = []
|
||||
|
||||
|
||||
async def list_path_metadata_documents(config):
|
||||
return [{
|
||||
**document("doc-1", "API"), "doc_metadata": [{
|
||||
"name": "path",
|
||||
"value": "guides/api"
|
||||
}]
|
||||
}]
|
||||
|
||||
|
||||
async def record_empty_custom_slug_search(config, endpoint, body):
|
||||
custom_slug_search_bodies.append(body)
|
||||
return {"records": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_scopes_folder_to_all_documents(monkeypatch):
|
||||
from mirage.core.dify import search, tree
|
||||
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def list_documents(config):
|
||||
return [
|
||||
document("doc-1", "API", "guides/api"),
|
||||
document("doc-2", "Auth", "guides/auth"),
|
||||
document("doc-3", "README.md"),
|
||||
]
|
||||
|
||||
async def dify_post(config, endpoint, body):
|
||||
bodies.append(body)
|
||||
return {
|
||||
"records": [{
|
||||
"segment": {
|
||||
"content": "api segment",
|
||||
"document": {
|
||||
"name": "API",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "guides/api"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.92,
|
||||
}, {
|
||||
"segment": {
|
||||
"content": "auth segment",
|
||||
"document": {
|
||||
"name": "Auth",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "guides/auth"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.81,
|
||||
}]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(search, "dify_post", dify_post)
|
||||
|
||||
result = await search.search_segments(
|
||||
accessor(),
|
||||
"login docs",
|
||||
[
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides",
|
||||
directory="/knowledge/guides")
|
||||
],
|
||||
RAMIndexCacheStore(),
|
||||
method="hybrid",
|
||||
top_k=2,
|
||||
threshold=0.4,
|
||||
)
|
||||
|
||||
assert result == (b"/knowledge/guides/api:0.92\napi segment\n"
|
||||
b"/knowledge/guides/auth:0.81\nauth segment\n")
|
||||
retrieval = bodies[0]["retrieval_model"]
|
||||
assert retrieval["search_method"] == "hybrid_search"
|
||||
assert retrieval["top_k"] == 2
|
||||
assert retrieval["score_threshold_enabled"] is True
|
||||
assert retrieval["score_threshold"] == 0.4
|
||||
assert retrieval["metadata_filtering_conditions"] == {
|
||||
"logical_operator":
|
||||
"or",
|
||||
"conditions": [{
|
||||
"name": "slug",
|
||||
"comparison_operator": "in",
|
||||
"value": ["guides/api", "guides/auth"],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_unions_slug_and_name_based_paths(monkeypatch):
|
||||
from mirage.core.dify import search, tree
|
||||
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def list_documents(config):
|
||||
return [
|
||||
document("doc-1", "API", "guides/api"),
|
||||
document("doc-2", "README.md"),
|
||||
]
|
||||
|
||||
async def dify_post(config, endpoint, body):
|
||||
bodies.append(body)
|
||||
return {"records": []}
|
||||
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(search, "dify_post", dify_post)
|
||||
|
||||
result = await search.search_segments(
|
||||
accessor(),
|
||||
"setup",
|
||||
[
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides/api",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/api",
|
||||
directory="/knowledge/guides/api"),
|
||||
PathSpec(resource_path=mount_key("/knowledge/README.md",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/README.md",
|
||||
directory="/knowledge/README.md"),
|
||||
],
|
||||
RAMIndexCacheStore(),
|
||||
)
|
||||
|
||||
assert result == b""
|
||||
assert bodies[0]["retrieval_model"]["metadata_filtering_conditions"] == {
|
||||
"logical_operator":
|
||||
"or",
|
||||
"conditions": [{
|
||||
"name": "slug",
|
||||
"comparison_operator": "in",
|
||||
"value": ["guides/api"],
|
||||
}, {
|
||||
"name": "document_name",
|
||||
"comparison_operator": "in",
|
||||
"value": ["README.md"],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_uses_configured_slug_metadata_name(monkeypatch):
|
||||
from mirage.core.dify import search, tree
|
||||
|
||||
custom_slug_search_bodies.clear()
|
||||
monkeypatch.setattr(tree, "list_all_documents",
|
||||
list_path_metadata_documents)
|
||||
monkeypatch.setattr(search, "dify_post", record_empty_custom_slug_search)
|
||||
|
||||
await search.search_segments(
|
||||
custom_slug_accessor(),
|
||||
"setup",
|
||||
[
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides/api",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/api",
|
||||
directory="/knowledge/guides/api")
|
||||
],
|
||||
RAMIndexCacheStore(),
|
||||
)
|
||||
|
||||
assert custom_slug_search_bodies[0]["retrieval_model"][
|
||||
"metadata_filtering_conditions"] == {
|
||||
"logical_operator":
|
||||
"or",
|
||||
"conditions": [{
|
||||
"name": "path",
|
||||
"comparison_operator": "in",
|
||||
"value": ["guides/api"],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_empty_paths_searches_whole_dataset(monkeypatch):
|
||||
from mirage.core.dify import search
|
||||
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def dify_post(config, endpoint, body):
|
||||
bodies.append(body)
|
||||
return {
|
||||
"records": [{
|
||||
"segment": {
|
||||
"content": "dataset match",
|
||||
"document": {
|
||||
"name": "README.md",
|
||||
"doc_metadata": None,
|
||||
},
|
||||
},
|
||||
"score": 0.77,
|
||||
}]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(search, "dify_post", dify_post)
|
||||
|
||||
result = await search.search_segments(accessor(),
|
||||
"anything", [],
|
||||
RAMIndexCacheStore(),
|
||||
mount_prefix="/knowledge/")
|
||||
|
||||
assert result == b"/knowledge/README.md:0.77\ndataset match\n"
|
||||
assert "metadata_filtering_conditions" not in bodies[0]["retrieval_model"]
|
||||
|
||||
|
||||
def test_records_to_bytes_formats_absolute_paths_and_scores():
|
||||
from mirage.core.dify.search import records_to_bytes
|
||||
|
||||
result = records_to_bytes(
|
||||
[{
|
||||
"segment": {
|
||||
"content": "api segment",
|
||||
"document": {
|
||||
"name": "API",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "guides/api"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.92,
|
||||
}, {
|
||||
"segment": {
|
||||
"content": "auth segment",
|
||||
"document": {
|
||||
"name": "README.md",
|
||||
"doc_metadata": None,
|
||||
},
|
||||
},
|
||||
}],
|
||||
"slug",
|
||||
"/knowledge/",
|
||||
)
|
||||
|
||||
assert result == (b"/knowledge/guides/api:0.92\napi segment\n"
|
||||
b"/knowledge/README.md\nauth segment\n")
|
||||
|
||||
|
||||
def test_records_to_bytes_keeps_multiple_chunks_for_same_document():
|
||||
from mirage.core.dify.search import records_to_bytes
|
||||
|
||||
result = records_to_bytes(
|
||||
[{
|
||||
"segment": {
|
||||
"content": "first chunk",
|
||||
"document": {
|
||||
"name":
|
||||
"refunds.md",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "policies/refunds"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.82,
|
||||
}, {
|
||||
"segment": {
|
||||
"content": "second chunk",
|
||||
"document": {
|
||||
"name":
|
||||
"refunds.md",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "policies/refunds"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.79,
|
||||
}],
|
||||
"slug",
|
||||
"/knowledge/",
|
||||
)
|
||||
|
||||
assert result == (b"/knowledge/policies/refunds:0.82\nfirst chunk\n"
|
||||
b"/knowledge/policies/refunds:0.79\nsecond chunk\n")
|
||||
|
||||
|
||||
def test_records_to_bytes_skips_records_with_invalid_slug():
|
||||
from mirage.core.dify.search import records_to_bytes
|
||||
|
||||
result = records_to_bytes(
|
||||
[{
|
||||
"segment": {
|
||||
"content": "bad chunk",
|
||||
"document": {
|
||||
"name": "ok.md",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": ""
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.82,
|
||||
}, {
|
||||
"segment": {
|
||||
"content": "good chunk",
|
||||
"document": {
|
||||
"name":
|
||||
"refunds.md",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "policies/refunds"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": 0.79,
|
||||
}],
|
||||
"slug",
|
||||
"/knowledge/",
|
||||
)
|
||||
|
||||
assert result == b"/knowledge/policies/refunds:0.79\ngood chunk\n"
|
||||
|
||||
|
||||
def test_records_to_bytes_omits_score_for_non_numeric_values():
|
||||
from mirage.core.dify.search import records_to_bytes
|
||||
|
||||
result = records_to_bytes(
|
||||
[{
|
||||
"segment": {
|
||||
"content": "chunk",
|
||||
"document": {
|
||||
"name":
|
||||
"refunds.md",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "policies/refunds"
|
||||
}],
|
||||
},
|
||||
},
|
||||
"score": True,
|
||||
}],
|
||||
"slug",
|
||||
"/knowledge/",
|
||||
)
|
||||
|
||||
assert result == b"/knowledge/policies/refunds\nchunk\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_caps_top_k_at_dify_limit(monkeypatch):
|
||||
from mirage.core.dify import search
|
||||
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def dify_post(config, endpoint, body):
|
||||
bodies.append(body)
|
||||
return {"records": []}
|
||||
|
||||
monkeypatch.setattr(search, "dify_post", dify_post)
|
||||
|
||||
await search.search_segments(accessor(),
|
||||
"anything", [],
|
||||
RAMIndexCacheStore(),
|
||||
top_k=150)
|
||||
|
||||
assert bodies[0]["retrieval_model"]["top_k"] == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_segments_validates_arguments():
|
||||
from mirage.core.dify.search import search_segments
|
||||
|
||||
with pytest.raises(ValueError, match="search: query is required"):
|
||||
await search_segments(accessor(), "", [], RAMIndexCacheStore())
|
||||
with pytest.raises(ValueError, match="search: top-k must be positive"):
|
||||
await search_segments(accessor(),
|
||||
"x", [],
|
||||
RAMIndexCacheStore(),
|
||||
top_k=0)
|
||||
with pytest.raises(ValueError, match="search: threshold must be in"):
|
||||
await search_segments(accessor(),
|
||||
"x", [],
|
||||
RAMIndexCacheStore(),
|
||||
threshold=1.2)
|
||||
with pytest.raises(ValueError, match="search: method must be one of"):
|
||||
await search_segments(accessor(),
|
||||
"x", [],
|
||||
RAMIndexCacheStore(),
|
||||
method="bad")
|
||||
@@ -0,0 +1,95 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def document(
|
||||
document_id: str,
|
||||
name: str,
|
||||
*,
|
||||
slug: str,
|
||||
size: int = 123,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": document_id,
|
||||
"name": name,
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": slug
|
||||
}],
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"tokens": 9,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": size
|
||||
}
|
||||
},
|
||||
"created_at": 1716282000,
|
||||
}
|
||||
|
||||
|
||||
def accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(slug_metadata_name="slug"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_light_uses_index_entry_without_detail_call(monkeypatch):
|
||||
from mirage.core.dify import stat, tree
|
||||
|
||||
async def list_documents(config):
|
||||
return [document("doc-1", "Quickstart", slug="guides/quickstart")]
|
||||
|
||||
async def get_detail(config, document_id):
|
||||
raise AssertionError("stat_light should not fetch document detail")
|
||||
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(stat, "get_document_detail", get_detail)
|
||||
|
||||
index = RAMIndexCacheStore()
|
||||
path = PathSpec(
|
||||
resource_path=mount_key("/knowledge/guides/quickstart", "/knowledge"),
|
||||
virtual="/knowledge/guides/quickstart",
|
||||
directory="/knowledge/guides/quickstart",
|
||||
)
|
||||
|
||||
item = await stat.stat_light(accessor(), path, index)
|
||||
|
||||
assert item.name == "quickstart"
|
||||
assert item.type == FileType.TEXT
|
||||
assert item.size == 123
|
||||
assert item.modified == "2024-05-21T09:00:00+00:00"
|
||||
assert item.extra["slug"] == "guides/quickstart"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_light_returns_directory_without_detail_call(monkeypatch):
|
||||
from mirage.core.dify import stat, tree
|
||||
|
||||
async def list_documents(config):
|
||||
return [document("doc-1", "Quickstart", slug="guides/quickstart")]
|
||||
|
||||
async def get_detail(config, document_id):
|
||||
raise AssertionError("stat_light should not fetch document detail")
|
||||
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(stat, "get_document_detail", get_detail)
|
||||
|
||||
index = RAMIndexCacheStore()
|
||||
path = PathSpec(
|
||||
resource_path=mount_key("/knowledge/guides", "/knowledge"),
|
||||
virtual="/knowledge/guides",
|
||||
directory="/knowledge/guides",
|
||||
)
|
||||
|
||||
item = await stat.stat_light(accessor(), path, index)
|
||||
|
||||
assert item.name == "guides"
|
||||
assert item.type == FileType.DIRECTORY
|
||||
assert item.extra == {"children_count": 0}
|
||||
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import tree
|
||||
from mirage.core.dify._client import is_visible_document
|
||||
|
||||
from .conftest import document
|
||||
|
||||
tree_calls = {"documents": 0}
|
||||
|
||||
|
||||
async def list_documents_with_hidden_and_fallbacks(config):
|
||||
# Mirrors the list_all_documents contract: hidden documents (archived,
|
||||
# disabled, still indexing) never leave the client.
|
||||
documents = [
|
||||
document("doc-1", "Quickstart", slug="guides/quickstart", size=333),
|
||||
document("doc-2", "API", slug="api", size=444, archived=True),
|
||||
document("doc-3", "Draft", slug="draft", indexing_status="indexing"),
|
||||
document("doc-4", "Disabled", slug="disabled", enabled=False),
|
||||
document("doc-5", "Archived", slug="archived", archived=True),
|
||||
document("doc-6", "README.md", size=None),
|
||||
]
|
||||
return [doc for doc in documents if is_visible_document(doc)]
|
||||
|
||||
|
||||
async def counted_documents(config):
|
||||
tree_calls["documents"] += 1
|
||||
return await list_documents_with_hidden_and_fallbacks(config)
|
||||
|
||||
|
||||
async def duplicate_documents(config):
|
||||
return [
|
||||
document("doc-1", "one", slug="same"),
|
||||
document("doc-2", "two", slug="same"),
|
||||
]
|
||||
|
||||
|
||||
async def collision_documents(config):
|
||||
return [
|
||||
document("doc-1", "foo", slug="foo"),
|
||||
document("doc-2", "bar", slug="foo/bar"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_tree_builds_prefixed_entries_and_uses_api_size(
|
||||
monkeypatch, dify_accessor, dify_index):
|
||||
tree_calls["documents"] = 0
|
||||
monkeypatch.setattr(tree, "list_all_documents", counted_documents)
|
||||
|
||||
await tree.ensure_tree(dify_accessor, dify_index, "/knowledge/")
|
||||
|
||||
root = await dify_index.list_dir("/knowledge")
|
||||
guides = await dify_index.list_dir("/knowledge/guides")
|
||||
quickstart = await dify_index.get("/knowledge/guides/quickstart")
|
||||
readme = await dify_index.get("/knowledge/README.md")
|
||||
|
||||
assert root.entries == ["/knowledge/README.md", "/knowledge/guides"]
|
||||
assert guides.entries == ["/knowledge/guides/quickstart"]
|
||||
assert quickstart.entry.id == "doc-1"
|
||||
assert quickstart.entry.size == 333
|
||||
assert quickstart.entry.extra["slug"] == "guides/quickstart"
|
||||
assert quickstart.entry.extra["raw_slug"] == "guides/quickstart"
|
||||
assert quickstart.entry.extra["has_slug"] is True
|
||||
assert readme.entry.id == "doc-6"
|
||||
assert readme.entry.size is None
|
||||
assert readme.entry.extra["raw_slug"] == "README.md"
|
||||
assert readme.entry.extra["has_slug"] is False
|
||||
|
||||
await tree.ensure_tree(dify_accessor, dify_index, "/knowledge/")
|
||||
assert tree_calls["documents"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_tree_rejects_duplicate_and_path_collision(
|
||||
monkeypatch, dify_accessor, dify_index):
|
||||
monkeypatch.setattr(tree, "list_all_documents", duplicate_documents)
|
||||
with pytest.raises(ValueError, match="Duplicate slug 'same'"):
|
||||
await tree.ensure_tree(dify_accessor, dify_index, "")
|
||||
|
||||
await dify_index.clear()
|
||||
monkeypatch.setattr(tree, "list_all_documents", collision_documents)
|
||||
with pytest.raises(ValueError, match="Path collision"):
|
||||
await tree.ensure_tree(dify_accessor, dify_index, "")
|
||||
|
||||
|
||||
def test_tree_slug_and_timestamp_helpers():
|
||||
assert tree.extract_slug({
|
||||
"doc_metadata": {
|
||||
"slug": "a/b"
|
||||
},
|
||||
"name": "fallback"
|
||||
}) == ("a/b", True)
|
||||
assert tree.extract_slug(
|
||||
{
|
||||
"doc_metadata": [{
|
||||
"name": "path",
|
||||
"value": "docs/start"
|
||||
}],
|
||||
"name": "fallback"
|
||||
}, "path") == ("docs/start", True)
|
||||
assert tree.extract_slug(
|
||||
{
|
||||
"doc_metadata": {
|
||||
"path": "docs/map"
|
||||
},
|
||||
"name": "fallback"
|
||||
}, "path") == ("docs/map", True)
|
||||
assert tree.normalize_slug("/a//b/") == "/a/b"
|
||||
assert tree.extract_document_size(
|
||||
{"data_source_info": {
|
||||
"upload_file": {
|
||||
"size": 7
|
||||
}
|
||||
}}) == 7
|
||||
assert tree.timestamp_to_iso(None) == ""
|
||||
assert tree.virtual_path("/a", "/knowledge/") == "/knowledge/a"
|
||||
assert tree.parent("/a/b") == "/a"
|
||||
assert tree.gnu_basename("/a/b") == "b"
|
||||
|
||||
|
||||
def test_normalize_slug_rejects_invalid_segments():
|
||||
with pytest.raises(ValueError, match="Invalid empty"):
|
||||
tree.normalize_slug("/")
|
||||
with pytest.raises(ValueError, match="Invalid Dify document slug segment"):
|
||||
tree.normalize_slug("../x")
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from mirage.core.dify import tree, walk
|
||||
|
||||
from .conftest import list_nested_documents, no_documents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_returns_recursive_paths_with_depth_controls(
|
||||
monkeypatch, dify_accessor, dify_index, knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
|
||||
full = await walk.walk(dify_accessor,
|
||||
knowledge_root,
|
||||
dify_index,
|
||||
include_root=True,
|
||||
strip_prefix=True)
|
||||
shallow = await walk.walk(dify_accessor,
|
||||
knowledge_root,
|
||||
dify_index,
|
||||
include_root=True,
|
||||
maxdepth=1,
|
||||
strip_prefix=True)
|
||||
|
||||
assert full == [
|
||||
"/",
|
||||
"/README.md",
|
||||
"/guides",
|
||||
"/guides/deep",
|
||||
"/guides/deep/note",
|
||||
"/guides/quickstart",
|
||||
]
|
||||
assert shallow == ["/", "/README.md", "/guides"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_can_ignore_missing_paths(dify_accessor, dify_index,
|
||||
guide_path, monkeypatch):
|
||||
monkeypatch.setattr(tree, "list_all_documents", no_documents)
|
||||
|
||||
assert await walk.walk(dify_accessor,
|
||||
guide_path,
|
||||
dify_index,
|
||||
ignore_missing=True) == []
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,87 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.channels import list_channels
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="test-bot-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels(config):
|
||||
mock_data = [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"id": "C002",
|
||||
"name": "announcements",
|
||||
"type": 5
|
||||
},
|
||||
{
|
||||
"id": "C003",
|
||||
"name": "forum",
|
||||
"type": 15
|
||||
},
|
||||
{
|
||||
"id": "C004",
|
||||
"name": "voice-chat",
|
||||
"type": 2
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.channels.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await list_channels(config, "G001")
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0]["name"] == "general"
|
||||
assert result[1]["name"] == "announcements"
|
||||
assert result[2]["name"] == "forum"
|
||||
mock_get.assert_called_once_with(config, "/guilds/G001/channels")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels_filters_voice(config):
|
||||
mock_data = [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "voice-chat",
|
||||
"type": 2
|
||||
},
|
||||
{
|
||||
"id": "C002",
|
||||
"name": "stage",
|
||||
"type": 13
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.channels.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await list_channels(config, "G001")
|
||||
|
||||
assert len(result) == 0
|
||||
@@ -0,0 +1,108 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord._client import discord_get, discord_post
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="test-bot-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_get_success(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=[
|
||||
{
|
||||
"id": "123",
|
||||
"name": "general"
|
||||
},
|
||||
])
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_resp),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
))
|
||||
|
||||
with patch("mirage.core.discord._client.aiohttp.ClientSession") as mock_cs:
|
||||
mock_cs.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_cs.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await discord_get(config, "/guilds/123/channels")
|
||||
|
||||
assert result == [{"id": "123", "name": "general"}]
|
||||
mock_session.get.assert_called_once()
|
||||
call_kwargs = mock_session.get.call_args
|
||||
assert call_kwargs.args[0] == \
|
||||
"https://discord.com/api/v10/guilds/123/channels"
|
||||
assert call_kwargs.kwargs["headers"]["Authorization"] == \
|
||||
"Bot test-bot-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_get_rate_limited(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 429
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"retry_after": 5,
|
||||
})
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_resp),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
))
|
||||
|
||||
with patch("mirage.core.discord._client.aiohttp.ClientSession") as mock_cs:
|
||||
mock_cs.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_cs.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Rate limited"):
|
||||
await discord_get(config, "/users/@me/guilds")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_post_success(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"id": "msg1",
|
||||
"content": "hello",
|
||||
})
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_resp),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
))
|
||||
|
||||
with patch("mirage.core.discord._client.aiohttp.ClientSession") as mock_cs:
|
||||
mock_cs.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_cs.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await discord_post(config,
|
||||
"/channels/C001/messages",
|
||||
body={"content": "hello"})
|
||||
|
||||
assert result["id"] == "msg1"
|
||||
mock_session.post.assert_called_once()
|
||||
call_kwargs = mock_session.post.call_args
|
||||
assert call_kwargs.args[0] == \
|
||||
"https://discord.com/api/v10/channels/C001/messages"
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.discord.entry import (channel_dirname, channel_entry,
|
||||
guild_dirname, guild_entry,
|
||||
member_entry, member_filename)
|
||||
|
||||
|
||||
def test_guild_dirname_basic():
|
||||
assert guild_dirname({"id": "G1", "name": "My Server"}) == "My Server__G1"
|
||||
|
||||
|
||||
def test_guild_dirname_apostrophe_is_preserved():
|
||||
assert guild_dirname({
|
||||
"id": "G1",
|
||||
"name": "Zecheng's Server"
|
||||
}) == "Zecheng's Server__G1"
|
||||
|
||||
|
||||
def test_guild_dirname_slash_in_name_is_replaced():
|
||||
assert guild_dirname({"id": "G1", "name": "A/B Test"}) == "A∕B Test__G1"
|
||||
|
||||
|
||||
def test_guild_dirname_falls_back_to_unknown_when_name_missing():
|
||||
assert guild_dirname({"id": "G1"}) == "unknown__G1"
|
||||
|
||||
|
||||
def test_guild_dirname_falls_back_to_unknown_when_name_empty():
|
||||
assert guild_dirname({"id": "G1", "name": ""}) == "unknown__G1"
|
||||
|
||||
|
||||
def test_channel_dirname_basic():
|
||||
assert channel_dirname({"id": "C1", "name": "general"}) == "general__C1"
|
||||
|
||||
|
||||
def test_channel_dirname_with_emoji_is_preserved():
|
||||
assert channel_dirname({"id": "C1", "name": "🔥-deals"}) == "🔥-deals__C1"
|
||||
|
||||
|
||||
def test_channel_dirname_falls_back_to_unknown():
|
||||
assert channel_dirname({"id": "C1"}) == "unknown__C1"
|
||||
|
||||
|
||||
def test_member_filename_basic():
|
||||
member = {"user": {"id": "U1", "username": "alice"}}
|
||||
assert member_filename(member) == "alice__U1.json"
|
||||
|
||||
|
||||
def test_member_filename_preserves_special_chars():
|
||||
member = {"user": {"id": "U1", "username": "bob.smith!"}}
|
||||
assert member_filename(member) == "bob.smith!__U1.json"
|
||||
|
||||
|
||||
def test_member_filename_falls_back_to_unknown():
|
||||
member = {"user": {"id": "U1"}}
|
||||
assert member_filename(member) == "unknown__U1.json"
|
||||
|
||||
|
||||
def test_same_named_members_get_distinct_filenames():
|
||||
a = {"user": {"id": "U1", "username": "alice"}}
|
||||
b = {"user": {"id": "U2", "username": "alice"}}
|
||||
assert member_filename(a) != member_filename(b)
|
||||
assert member_filename(a) == "alice__U1.json"
|
||||
assert member_filename(b) == "alice__U2.json"
|
||||
|
||||
|
||||
def test_same_named_channels_get_distinct_dirnames():
|
||||
a = {"id": "C1", "name": "general"}
|
||||
b = {"id": "C2", "name": "general"}
|
||||
assert channel_dirname(a) != channel_dirname(b)
|
||||
|
||||
|
||||
def test_same_named_guilds_get_distinct_dirnames():
|
||||
a = {"id": "G1", "name": "Test"}
|
||||
b = {"id": "G2", "name": "Test"}
|
||||
assert guild_dirname(a) != guild_dirname(b)
|
||||
|
||||
|
||||
def test_guild_entry_vfs_name_matches_dirname():
|
||||
g = {"id": "G1", "name": "My Server"}
|
||||
assert guild_entry(g).vfs_name == guild_dirname(g)
|
||||
|
||||
|
||||
def test_channel_entry_vfs_name_matches_dirname():
|
||||
c = {"id": "C1", "name": "general", "last_message_id": "M1"}
|
||||
assert channel_entry(c).vfs_name == channel_dirname(c)
|
||||
|
||||
|
||||
def test_member_entry_vfs_name_matches_filename():
|
||||
m = {"user": {"id": "U1", "username": "alice"}}
|
||||
assert member_entry(m).vfs_name == member_filename(m)
|
||||
@@ -0,0 +1,86 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.discord.entry import channel_dirname, guild_dirname
|
||||
from mirage.core.discord.formatters import format_grep_results
|
||||
|
||||
|
||||
def test_format_grep_results_uses_vfs_channel_dirname():
|
||||
msgs = [{
|
||||
"timestamp": "2024-04-10T12:34:56.000000+00:00",
|
||||
"channel_id": "C1",
|
||||
"author": {
|
||||
"username": "alice"
|
||||
},
|
||||
"content": "hello",
|
||||
}]
|
||||
guild = {"id": "G1", "name": "MyGuild"}
|
||||
chan = {"id": "C1", "name": "general"}
|
||||
lines = format_grep_results(
|
||||
msgs,
|
||||
prefix="/discord",
|
||||
guild_dirname=guild_dirname(guild),
|
||||
channel_names={chan["id"]: channel_dirname(chan)})
|
||||
assert lines == [
|
||||
"/discord/MyGuild__G1/channels/general__C1/"
|
||||
"2024-04-10/chat.jsonl:[alice] hello"
|
||||
]
|
||||
|
||||
|
||||
def test_format_grep_results_falls_back_to_channel_id():
|
||||
msgs = [{
|
||||
"timestamp": "2024-04-10T00:00:00+00:00",
|
||||
"channel_id": "C2",
|
||||
"author": {
|
||||
"username": "bob"
|
||||
},
|
||||
"content": "x",
|
||||
}]
|
||||
lines = format_grep_results(msgs, "/discord", "G__G1", channel_names={})
|
||||
assert lines == [
|
||||
"/discord/G__G1/channels/C2/2024-04-10/chat.jsonl:[bob] x"
|
||||
]
|
||||
|
||||
|
||||
def test_format_grep_results_path_matches_vfs_layout():
|
||||
msgs = [{
|
||||
"timestamp": "2024-04-10T00:00:00+00:00",
|
||||
"channel_id": "C1",
|
||||
"author": {
|
||||
"username": "alice"
|
||||
},
|
||||
"content": "hi",
|
||||
}]
|
||||
guild = {"id": "G1", "name": "S"}
|
||||
chan = {"id": "C1", "name": "general"}
|
||||
line = format_grep_results(msgs, "/discord", guild_dirname(guild),
|
||||
{chan["id"]: channel_dirname(chan)})[0]
|
||||
expected_path = (f"/discord/{guild_dirname(guild)}/channels/"
|
||||
f"{channel_dirname(chan)}/2024-04-10/chat.jsonl")
|
||||
assert line.startswith(expected_path + ":")
|
||||
|
||||
|
||||
def test_format_grep_results_replaces_newlines():
|
||||
msgs = [{
|
||||
"timestamp": "2024-01-02",
|
||||
"channel_id": "C",
|
||||
"author": {
|
||||
"username": "u"
|
||||
},
|
||||
"content": "line1\nline2",
|
||||
}]
|
||||
lines = format_grep_results(msgs, "/discord", "G__G1", channel_names={})
|
||||
assert lines == [
|
||||
"/discord/G__G1/channels/C/2024-01-02/chat.jsonl:[u] line1 line2"
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.guilds import list_guilds
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="test-bot-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_guilds(config):
|
||||
mock_data = [
|
||||
{
|
||||
"id": "G001",
|
||||
"name": "My Server"
|
||||
},
|
||||
{
|
||||
"id": "G002",
|
||||
"name": "Another Server"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await list_guilds(config)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "My Server"
|
||||
assert result[1]["id"] == "G002"
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert args[1] == "/users/@me/guilds"
|
||||
assert kwargs["params"]["after"] == "0"
|
||||
assert kwargs["params"]["limit"] == 200
|
||||
@@ -0,0 +1,66 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.history import get_history_jsonl
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="test-bot-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_jsonl(config):
|
||||
messages = [
|
||||
{
|
||||
"id": "200",
|
||||
"content": "second"
|
||||
},
|
||||
{
|
||||
"id": "100",
|
||||
"content": "first"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=messages,
|
||||
):
|
||||
result = await get_history_jsonl(config, "C001", "2024-01-15")
|
||||
|
||||
lines = result.decode().strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
first = json.loads(lines[0])
|
||||
second = json.loads(lines[1])
|
||||
assert int(first["id"]) < int(second["id"])
|
||||
assert first["content"] == "first"
|
||||
assert second["content"] == "second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_empty(config):
|
||||
with patch(
|
||||
"mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
result = await get_history_jsonl(config, "C001", "2024-01-15")
|
||||
|
||||
assert result == b""
|
||||
@@ -0,0 +1,86 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.members import list_members, search_members
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="test-bot-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_members(config):
|
||||
mock_data = [
|
||||
{
|
||||
"user": {
|
||||
"id": "U001",
|
||||
"username": "alice"
|
||||
}
|
||||
},
|
||||
{
|
||||
"user": {
|
||||
"id": "U002",
|
||||
"username": "bob"
|
||||
}
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await list_members(config, "G001")
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["user"]["username"] == "alice"
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert args[1] == "/guilds/G001/members"
|
||||
assert kwargs["params"]["after"] == "0"
|
||||
assert kwargs["params"]["limit"] == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_members(config):
|
||||
mock_data = [
|
||||
{
|
||||
"user": {
|
||||
"id": "U001",
|
||||
"username": "alice"
|
||||
}
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.members.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await search_members(config, "G001", "ali")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["user"]["username"] == "alice"
|
||||
mock_get.assert_called_once_with(
|
||||
config,
|
||||
"/guilds/G001/members/search",
|
||||
params={
|
||||
"query": "ali",
|
||||
"limit": 100
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.paginate import after_id_pages, offset_pages
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="t")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_id_pages_walks_until_partial(config):
|
||||
page1 = [{"id": "1"}, {"id": "2"}]
|
||||
page2 = [{"id": "3"}]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[page1, page2]) as mock:
|
||||
collected = []
|
||||
async for page in after_id_pages(
|
||||
config,
|
||||
"/x",
|
||||
base_params={},
|
||||
last_id_fn=lambda m: m["id"],
|
||||
page_size=2,
|
||||
):
|
||||
collected.append(page)
|
||||
assert collected == [page1, page2]
|
||||
assert mock.call_count == 2
|
||||
second_params = mock.call_args_list[1].kwargs["params"]
|
||||
assert second_params["after"] == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_id_pages_stops_on_empty(config):
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[]):
|
||||
pages = [
|
||||
p async for p in after_id_pages(
|
||||
config,
|
||||
"/x",
|
||||
base_params={},
|
||||
last_id_fn=lambda m: m["id"],
|
||||
)
|
||||
]
|
||||
assert pages == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_pages_walks_and_terminates_on_total(config):
|
||||
p1 = {"messages": [["m1"], ["m2"]], "total_results": 3}
|
||||
p2 = {"messages": [["m3"]], "total_results": 3}
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[p1, p2]) as mock:
|
||||
collected = []
|
||||
async for items in offset_pages(
|
||||
config,
|
||||
"/y",
|
||||
base_params={"q": "x"},
|
||||
items_path=("messages", ),
|
||||
page_size=2,
|
||||
):
|
||||
collected.append(items)
|
||||
assert collected == [[["m1"], ["m2"]], [["m3"]]]
|
||||
assert mock.call_count == 2
|
||||
assert mock.call_args_list[0].kwargs["params"]["offset"] == 0
|
||||
assert mock.call_args_list[1].kwargs["params"]["offset"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offset_pages_respects_max_pages(config):
|
||||
p = {"messages": [["m"]], "total_results": 999}
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=p) as mock:
|
||||
collected = []
|
||||
async for items in offset_pages(
|
||||
config,
|
||||
"/y",
|
||||
base_params={},
|
||||
items_path=("messages", ),
|
||||
page_size=1,
|
||||
max_pages=2,
|
||||
):
|
||||
collected.append(items)
|
||||
assert len(collected) == 2
|
||||
assert mock.call_count == 2
|
||||
@@ -0,0 +1,78 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.discord.read import read
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
store = RAMIndexCacheStore()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"/My Server/channels/general",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="discord/channel",
|
||||
vfs_name="general",
|
||||
),
|
||||
))
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
config = DiscordConfig(token="test-bot-token")
|
||||
return DiscordAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_jsonl(accessor, index):
|
||||
fake_data = b'{"id":"100","content":"hello"}\n'
|
||||
with patch(
|
||||
"mirage.core.discord.read.get_history_jsonl",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_data,
|
||||
) as mock_hist:
|
||||
path = "/My Server/channels/general/2024-01-15/chat.jsonl"
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(virtual=path,
|
||||
directory=path,
|
||||
resource_path=path.strip("/")),
|
||||
index,
|
||||
)
|
||||
|
||||
assert result == fake_data
|
||||
mock_hist.assert_called_once_with(accessor.config, "C001", "2024-01-15")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_not_found(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="no/such/path",
|
||||
virtual="/no/such/path",
|
||||
directory="/no/such/path"), index)
|
||||
@@ -0,0 +1,190 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.discord.readdir import readdir
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return DiscordAccessor(config=DiscordConfig(token="test-bot-token"), )
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
guilds = [
|
||||
{
|
||||
"id": "G001",
|
||||
"name": "My Server"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.readdir.list_guilds",
|
||||
new_callable=AsyncMock,
|
||||
return_value=guilds,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
|
||||
assert "/My Server__G001" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_with_slash_in_name(accessor, index):
|
||||
guilds = [
|
||||
{
|
||||
"id": "G001",
|
||||
"name": "A/B Test Server"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.readdir.list_guilds",
|
||||
new_callable=AsyncMock,
|
||||
return_value=guilds,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
|
||||
assert result == ["/A∕B Test Server__G001"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_with_apostrophe(accessor, index):
|
||||
guilds = [
|
||||
{
|
||||
"id": "G001",
|
||||
"name": "Zecheng's Server"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.readdir.list_guilds",
|
||||
new_callable=AsyncMock,
|
||||
return_value=guilds,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
|
||||
assert "/Zecheng's Server__G001" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_guild(accessor, index):
|
||||
await index.put(
|
||||
"/My Server",
|
||||
IndexEntry(
|
||||
id="G001",
|
||||
name="My Server",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="My Server",
|
||||
),
|
||||
)
|
||||
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server",
|
||||
virtual="/My Server",
|
||||
directory="/My Server"), index)
|
||||
|
||||
assert result == [
|
||||
"/My Server/channels",
|
||||
"/My Server/members",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channels(accessor, index):
|
||||
await index.put(
|
||||
"/My Server",
|
||||
IndexEntry(
|
||||
id="G001",
|
||||
name="My Server",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="My Server",
|
||||
),
|
||||
)
|
||||
channels = [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"id": "C002",
|
||||
"name": "random",
|
||||
"type": 0
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.discord.readdir.list_channels",
|
||||
new_callable=AsyncMock,
|
||||
return_value=channels,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server/channels",
|
||||
virtual="/My Server/channels",
|
||||
directory="/My Server/channels"), index)
|
||||
|
||||
assert "/My Server/channels/general__C001" in result
|
||||
assert "/My Server/channels/random__C002" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channel_dates(accessor, index):
|
||||
await index.put(
|
||||
"/My Server",
|
||||
IndexEntry(
|
||||
id="G001",
|
||||
name="My Server",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="My Server",
|
||||
),
|
||||
)
|
||||
await index.put(
|
||||
"/My Server/channels/general",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="discord/channel",
|
||||
vfs_name="general",
|
||||
),
|
||||
)
|
||||
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server/channels/general",
|
||||
virtual="/My Server/channels/general",
|
||||
directory="/My Server/channels/general"), index)
|
||||
|
||||
assert len(result) >= 1
|
||||
# New layout: date directories (no extension)
|
||||
import re
|
||||
date_re = re.compile(r"^/My Server/channels/general/\d{4}-\d{2}-\d{2}$")
|
||||
assert all(date_re.match(r) for r in result)
|
||||
@@ -0,0 +1,153 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.discord.readdir import readdir
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return DiscordAccessor(config=DiscordConfig(token="t"))
|
||||
|
||||
|
||||
async def _seed_channel(idx):
|
||||
await idx.put(
|
||||
"/G",
|
||||
IndexEntry(id="G1",
|
||||
name="G",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="G"),
|
||||
)
|
||||
await idx.put(
|
||||
"/G/channels/ch",
|
||||
IndexEntry(id="C1",
|
||||
name="ch",
|
||||
resource_type="discord/channel",
|
||||
vfs_name="ch"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_dir_contents_lists_chat_and_files(accessor, index):
|
||||
fake_messages = [{
|
||||
"id":
|
||||
"100",
|
||||
"content":
|
||||
"hi",
|
||||
"attachments": [{
|
||||
"id": "A1",
|
||||
"filename": "pic.png",
|
||||
"url": "https://cdn.example/A1/pic.png",
|
||||
"proxy_url": "https://media.example/A1/pic.png",
|
||||
"content_type": "image/png",
|
||||
"size": 1234,
|
||||
}],
|
||||
}]
|
||||
await _seed_channel(index)
|
||||
with patch("mirage.core.discord.readdir.list_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_messages):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="G/channels/ch/2024-04-04",
|
||||
virtual="/G/channels/ch/2024-04-04",
|
||||
directory="/G/channels/ch/2024-04-04"),
|
||||
index,
|
||||
)
|
||||
assert "/G/channels/ch/2024-04-04/chat.jsonl" in result
|
||||
assert "/G/channels/ch/2024-04-04/files" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_dir_lists_attachments(accessor, index):
|
||||
fake_messages = [{
|
||||
"id":
|
||||
"100",
|
||||
"attachments": [{
|
||||
"id": "A1",
|
||||
"filename": "pic.png",
|
||||
"url": "https://cdn.example/A1/pic.png",
|
||||
"content_type": "image/png",
|
||||
"size": 1234,
|
||||
}],
|
||||
}]
|
||||
await _seed_channel(index)
|
||||
with patch("mirage.core.discord.readdir.list_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_messages):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="G/channels/ch/2024-04-04/files",
|
||||
virtual="/G/channels/ch/2024-04-04/files",
|
||||
directory="/G/channels/ch/2024-04-04/files"),
|
||||
index,
|
||||
)
|
||||
assert any(r.endswith("pic__A1.png") for r in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_day_swallows_soft_errors(accessor, index):
|
||||
await _seed_channel(index)
|
||||
err = aiohttp.ClientResponseError(
|
||||
request_info=None, # type: ignore[arg-type]
|
||||
history=(),
|
||||
status=403,
|
||||
)
|
||||
with patch("mirage.core.discord.readdir.list_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=err):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="G/channels/ch/2024-04-04",
|
||||
virtual="/G/channels/ch/2024-04-04",
|
||||
directory="/G/channels/ch/2024-04-04"),
|
||||
index,
|
||||
)
|
||||
# empty day on soft error → sealed listing (no chat.jsonl/files entries)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_day_propagates_hard_errors(accessor, index):
|
||||
await _seed_channel(index)
|
||||
err = aiohttp.ClientResponseError(
|
||||
request_info=None, # type: ignore[arg-type]
|
||||
history=(),
|
||||
status=500,
|
||||
)
|
||||
with patch("mirage.core.discord.readdir.list_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=err):
|
||||
with pytest.raises(aiohttp.ClientResponseError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="G/channels/ch/2024-04-04",
|
||||
virtual="/G/channels/ch/2024-04-04",
|
||||
directory="/G/channels/ch/2024-04-04"),
|
||||
index,
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.discord.scope import coalesce_scopes, detect_scope
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _gs(path: str,
|
||||
prefix: str = "",
|
||||
pattern: str | None = None,
|
||||
resolved: bool = True) -> PathSpec:
|
||||
return PathSpec(
|
||||
resource_path=mount_key(path, prefix),
|
||||
virtual=path,
|
||||
directory=path.rsplit("/", 1)[0] + "/" if "/" in path else "/",
|
||||
pattern=pattern,
|
||||
resolved=resolved,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
idx = RAMIndexCacheStore(ttl=600)
|
||||
_run(
|
||||
idx.put(
|
||||
"/discord/TestGuild",
|
||||
IndexEntry(id="G1",
|
||||
name="TestGuild",
|
||||
resource_type="discord/guild")))
|
||||
_run(
|
||||
idx.put(
|
||||
"/discord/TestGuild/channels/general",
|
||||
IndexEntry(id="C1",
|
||||
name="general",
|
||||
resource_type="discord/channel")))
|
||||
return idx
|
||||
|
||||
|
||||
# ── root ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_root_empty():
|
||||
scope = _run(detect_scope("/"))
|
||||
assert scope.level == "root"
|
||||
|
||||
|
||||
def test_root_prefix():
|
||||
scope = _run(detect_scope(_gs("/discord/", prefix="/discord")))
|
||||
assert scope.level == "root"
|
||||
|
||||
|
||||
# ── guild ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_guild(index):
|
||||
scope = _run(
|
||||
detect_scope(_gs("/discord/TestGuild", prefix="/discord"), index))
|
||||
assert scope.level == "guild"
|
||||
assert scope.guild_id == "G1"
|
||||
|
||||
|
||||
def test_guild_channels(index):
|
||||
scope = _run(
|
||||
detect_scope(_gs("/discord/TestGuild/channels", prefix="/discord"),
|
||||
index))
|
||||
assert scope.level == "guild"
|
||||
assert scope.guild_id == "G1"
|
||||
|
||||
|
||||
def test_guild_members(index):
|
||||
scope = _run(
|
||||
detect_scope(_gs("/discord/TestGuild/members", prefix="/discord"),
|
||||
index))
|
||||
assert scope.level == "guild"
|
||||
assert scope.guild_id == "G1"
|
||||
|
||||
|
||||
# ── channel ───────────────────────────────────
|
||||
|
||||
|
||||
def test_channel(index):
|
||||
scope = _run(
|
||||
detect_scope(
|
||||
_gs("/discord/TestGuild/channels/general", prefix="/discord"),
|
||||
index))
|
||||
assert scope.level == "channel"
|
||||
assert scope.guild_id == "G1"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
# ── date / messages / files ────────────────────
|
||||
|
||||
|
||||
def test_date_dir(index):
|
||||
scope = _run(
|
||||
detect_scope(
|
||||
_gs("/discord/TestGuild/channels/general/2024-04-10",
|
||||
prefix="/discord"), index))
|
||||
assert scope.level == "date"
|
||||
assert scope.guild_id == "G1"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.date_str == "2024-04-10"
|
||||
|
||||
|
||||
def test_messages_file(index):
|
||||
scope = _run(
|
||||
detect_scope(
|
||||
_gs("/discord/TestGuild/channels/general/2024-04-10/chat.jsonl",
|
||||
prefix="/discord"), index))
|
||||
assert scope.level == "messages"
|
||||
assert scope.guild_id == "G1"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.date_str == "2024-04-10"
|
||||
|
||||
|
||||
def test_files_dir(index):
|
||||
scope = _run(
|
||||
detect_scope(
|
||||
_gs("/discord/TestGuild/channels/general/2024-04-10/files",
|
||||
prefix="/discord"), index))
|
||||
assert scope.level == "files"
|
||||
assert scope.guild_id == "G1"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.date_str == "2024-04-10"
|
||||
|
||||
|
||||
def test_file_blob(index):
|
||||
scope = _run(
|
||||
detect_scope(
|
||||
_gs(
|
||||
"/discord/TestGuild/channels/general/2024-04-10/files/"
|
||||
"img__A1.png",
|
||||
prefix="/discord"), index))
|
||||
assert scope.level == "file_blob"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.date_str == "2024-04-10"
|
||||
|
||||
|
||||
# ── PathSpec ─────────────────────────────────
|
||||
|
||||
|
||||
def test_glob_jsonl_in_channel(index):
|
||||
gs = PathSpec(
|
||||
resource_path=mount_key("/discord/TestGuild/channels/general/*.jsonl",
|
||||
"/discord"),
|
||||
virtual="/discord/TestGuild/channels/general/*.jsonl",
|
||||
directory="/discord/TestGuild/channels/general/",
|
||||
pattern="*.jsonl",
|
||||
resolved=False,
|
||||
)
|
||||
scope = _run(detect_scope(gs, index))
|
||||
assert scope.level == "channel"
|
||||
assert scope.guild_id == "G1"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_glob_specific_date(index):
|
||||
gs = PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/discord/TestGuild/channels/general/2024-04-*.jsonl", "/discord"),
|
||||
virtual="/discord/TestGuild/channels/general/2024-04-*.jsonl",
|
||||
directory="/discord/TestGuild/channels/general/",
|
||||
pattern="2024-04-*.jsonl",
|
||||
resolved=False,
|
||||
)
|
||||
scope = _run(detect_scope(gs, index))
|
||||
assert scope.level == "channel"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_glob_non_jsonl():
|
||||
gs = PathSpec(
|
||||
resource_path="discord/TestGuild/members/*.json",
|
||||
virtual="/discord/TestGuild/members/*.json",
|
||||
directory="/discord/TestGuild/members/",
|
||||
pattern="*.json",
|
||||
resolved=False,
|
||||
)
|
||||
scope = _run(detect_scope(gs))
|
||||
assert scope.level != "channel"
|
||||
|
||||
|
||||
# ── no index ──────────────────────────────────
|
||||
|
||||
|
||||
def test_guild_no_index():
|
||||
scope = _run(detect_scope("TestGuild"))
|
||||
assert scope.level == "guild"
|
||||
assert scope.guild_id is None
|
||||
|
||||
|
||||
def test_channel_no_index():
|
||||
scope = _run(detect_scope("TestGuild/channels/general"))
|
||||
assert scope.level == "channel"
|
||||
assert scope.guild_id is None
|
||||
assert scope.channel_id is None
|
||||
|
||||
|
||||
def _spec(path: str, prefix: str = "/discord") -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key(path, prefix),
|
||||
virtual=path,
|
||||
directory=path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_index():
|
||||
idx = AsyncMock()
|
||||
|
||||
async def _get(virtual_key):
|
||||
result = AsyncMock()
|
||||
if virtual_key.endswith("/myguild/channels/general"):
|
||||
result.entry = type("E", (), {"id": "ch_456"})
|
||||
elif virtual_key.endswith("/myguild"):
|
||||
result.entry = type("E", (), {"id": "g_123"})
|
||||
else:
|
||||
result.entry = None
|
||||
return result
|
||||
|
||||
idx.get.side_effect = _get
|
||||
return idx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coalesce_concrete_jsonl_paths_same_channel(fake_index):
|
||||
paths = [
|
||||
_spec(f"/discord/myguild/channels/general/2026-01-{d:02d}/chat.jsonl")
|
||||
for d in range(1, 8)
|
||||
]
|
||||
scope = await coalesce_scopes(paths, fake_index)
|
||||
assert scope is not None
|
||||
assert scope.level == "channel"
|
||||
assert scope.guild_id == "g_123"
|
||||
assert scope.channel_id == "ch_456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coalesce_returns_none_for_mixed_channels(fake_index):
|
||||
paths = [
|
||||
_spec("/discord/myguild/channels/general/2026-01-01/chat.jsonl"),
|
||||
_spec("/discord/myguild/channels/random/2026-01-01/chat.jsonl"),
|
||||
]
|
||||
assert await coalesce_scopes(paths, fake_index) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coalesce_empty_list_returns_none(fake_index):
|
||||
assert await coalesce_scopes([], fake_index) is None
|
||||
@@ -0,0 +1,147 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from mirage.core.discord.search import search_guild
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _config():
|
||||
return DiscordConfig(token="test-token")
|
||||
|
||||
|
||||
def _make_search_response(messages, total=None):
|
||||
hits = [[msg] for msg in messages]
|
||||
return {"messages": hits, "total_results": total or len(messages)}
|
||||
|
||||
|
||||
def test_search_returns_messages():
|
||||
msgs = [
|
||||
{
|
||||
"id": "100",
|
||||
"content": "hello world",
|
||||
"author": {
|
||||
"username": "a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "200",
|
||||
"content": "hello again",
|
||||
"author": {
|
||||
"username": "b"
|
||||
}
|
||||
},
|
||||
]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_search_response(msgs)):
|
||||
results = _run(search_guild(_config(), "G1", "hello"))
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "100"
|
||||
assert results[1]["id"] == "200"
|
||||
|
||||
|
||||
def test_search_with_channel_filter():
|
||||
msgs = [{"id": "300", "content": "test", "author": {"username": "c"}}]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_search_response(msgs)) as mock:
|
||||
results = _run(search_guild(_config(), "G1", "test", channel_id="C1"))
|
||||
assert len(results) == 1
|
||||
call_params = mock.call_args[1].get("params") or mock.call_args[0][2]
|
||||
assert call_params["channel_id"] == "C1"
|
||||
assert call_params["content"] == "test"
|
||||
|
||||
|
||||
def test_search_empty_results():
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"messages": [],
|
||||
"total_results": 0
|
||||
}):
|
||||
results = _run(search_guild(_config(), "G1", "nonexistent"))
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_sorted_oldest_first():
|
||||
msgs = [
|
||||
{
|
||||
"id": "500",
|
||||
"content": "newer",
|
||||
"author": {
|
||||
"username": "a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "100",
|
||||
"content": "older",
|
||||
"author": {
|
||||
"username": "b"
|
||||
}
|
||||
},
|
||||
]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_search_response(msgs)):
|
||||
results = _run(search_guild(_config(), "G1", "hello"))
|
||||
assert results[0]["id"] == "100"
|
||||
assert results[1]["id"] == "500"
|
||||
|
||||
|
||||
def test_search_respects_limit():
|
||||
msgs = [{
|
||||
"id": str(i),
|
||||
"content": f"msg{i}",
|
||||
"author": {
|
||||
"username": "a"
|
||||
}
|
||||
} for i in range(10)]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_search_response(msgs, total=10)):
|
||||
results = _run(search_guild(_config(), "G1", "msg", limit=3))
|
||||
assert len(results) == 3
|
||||
|
||||
|
||||
def test_search_paginates():
|
||||
page1 = [{
|
||||
"id": str(i),
|
||||
"content": f"msg{i}",
|
||||
"author": {
|
||||
"username": "a"
|
||||
}
|
||||
} for i in range(25)]
|
||||
page2 = [{
|
||||
"id": str(i + 25),
|
||||
"content": f"msg{i + 25}",
|
||||
"author": {
|
||||
"username": "a"
|
||||
}
|
||||
} for i in range(5)]
|
||||
responses = [
|
||||
_make_search_response(page1, total=30),
|
||||
_make_search_response(page2, total=30),
|
||||
]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=responses):
|
||||
results = _run(search_guild(_config(), "G1", "msg", limit=100))
|
||||
assert len(results) == 30
|
||||
@@ -0,0 +1,173 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.discord.stat import stat
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
store = RAMIndexCacheStore()
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"/My Server",
|
||||
IndexEntry(
|
||||
id="G001",
|
||||
name="My Server",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="My Server",
|
||||
),
|
||||
))
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"/My Server/channels/general",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="discord/channel",
|
||||
remote_time="794354201395200000",
|
||||
vfs_name="general",
|
||||
),
|
||||
))
|
||||
asyncio.run(
|
||||
store.put(
|
||||
"/My Server/members/alice.json",
|
||||
IndexEntry(
|
||||
id="U001",
|
||||
name="alice",
|
||||
resource_type="discord/member",
|
||||
vfs_name="alice.json",
|
||||
),
|
||||
))
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return DiscordAccessor(config=object())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(accessor, index):
|
||||
result = await stat(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_guild(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server",
|
||||
virtual="/My Server",
|
||||
directory="/My Server"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.extra["guild_id"] == "G001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_channel(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server/channels/general",
|
||||
virtual="/My Server/channels/general",
|
||||
directory="/My Server/channels/general"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.extra["channel_id"] == "C001"
|
||||
assert result.modified == "2021-01-01T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_member(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="My Server/members/alice.json",
|
||||
virtual="/My Server/members/alice.json",
|
||||
directory="/My Server/members/alice.json"), index)
|
||||
assert result.type == FileType.JSON
|
||||
assert result.extra["user_id"] == "U001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_date_dir(accessor, index):
|
||||
path = "/My Server/channels/general/2024-01-15"
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(virtual=path, directory=path, resource_path=path.strip("/")),
|
||||
index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "2024-01-15"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_chat_jsonl(accessor, index):
|
||||
path = "/My Server/channels/general/2024-01-15/chat.jsonl"
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(virtual=path, directory=path, resource_path=path.strip("/")),
|
||||
index)
|
||||
assert result.type == FileType.TEXT
|
||||
assert result.name == "chat.jsonl"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_files_dir(accessor, index):
|
||||
path = "/My Server/channels/general/2024-01-15/files"
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(virtual=path, directory=path, resource_path=path.strip("/")),
|
||||
index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "files"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_non_date_chat_jsonl_not_found(accessor, index):
|
||||
path = "/My Server/channels/general/notadate/chat.jsonl"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(virtual=path,
|
||||
directory=path,
|
||||
resource_path=path.strip("/")), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_non_date_files_dir_not_found(accessor, index):
|
||||
path = "/My Server/channels/general/notadate/files"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(virtual=path,
|
||||
directory=path,
|
||||
resource_path=path.strip("/")), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_not_found(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="nonexistent/path",
|
||||
virtual="/nonexistent/path",
|
||||
directory="/nonexistent/path"), index)
|
||||
@@ -0,0 +1,98 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.discord.guilds import list_guilds_stream
|
||||
from mirage.core.discord.history import (date_to_snowflake,
|
||||
stream_messages_for_day)
|
||||
from mirage.core.discord.members import list_members_stream
|
||||
from mirage.core.discord.search import search_guild_stream
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return DiscordConfig(token="t")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_guilds_stream_walks_pages(config):
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[[{
|
||||
"id": "G1"
|
||||
}, {
|
||||
"id": "G2"
|
||||
}], [{
|
||||
"id": "G3"
|
||||
}]]):
|
||||
collected = []
|
||||
async for page in list_guilds_stream(config, page_size=2):
|
||||
collected.append(page)
|
||||
assert collected == [[{"id": "G1"}, {"id": "G2"}], [{"id": "G3"}]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_members_stream_walks_user_ids(config):
|
||||
page1 = [{"user": {"id": "U1"}}, {"user": {"id": "U2"}}]
|
||||
page2 = [{"user": {"id": "U3"}}]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[page1, page2]) as mock:
|
||||
collected = []
|
||||
async for page in list_members_stream(config, "G1", page_size=2):
|
||||
collected.append(page)
|
||||
assert collected == [page1, page2]
|
||||
assert mock.call_args_list[1].kwargs["params"]["after"] == "U2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_messages_for_day_filters_by_date(config):
|
||||
before_int = int(date_to_snowflake("2024-01-15", end=True))
|
||||
in_range = [{"id": str(before_int - 1000), "content": "ok"}]
|
||||
out_of_range = [{"id": str(before_int + 1000), "content": "next-day"}]
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=in_range + out_of_range):
|
||||
pages = [
|
||||
p async for p in stream_messages_for_day(
|
||||
config, "C1", "2024-01-15", page_size=2)
|
||||
]
|
||||
flat = [m for page in pages for m in page]
|
||||
assert all(int(m["id"]) <= before_int for m in flat)
|
||||
assert any(m["content"] == "ok" for m in flat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_guild_stream_flattens_contexts(config):
|
||||
p1 = {
|
||||
"messages": [[{
|
||||
"id": "1"
|
||||
}], [{
|
||||
"id": "2"
|
||||
}]],
|
||||
"total_results": 30,
|
||||
}
|
||||
p2 = {"messages": [[{"id": "3"}]], "total_results": 30}
|
||||
with patch("mirage.core.discord.paginate.discord_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[p1, p2]):
|
||||
collected = []
|
||||
async for page in search_guild_stream(config, "G1", "x", max_pages=2):
|
||||
collected.append(page)
|
||||
flat = [m["id"] for page in collected for m in page]
|
||||
assert flat == ["1", "2", "3"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,53 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.core.disk.du import du, du_all
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_single_file(tmp_path):
|
||||
(tmp_path / "a.txt").write_bytes(b"hello")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await du(
|
||||
accessor,
|
||||
PathSpec(resource_path="a.txt", virtual="/a.txt", directory="/a.txt"))
|
||||
assert result == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_directory(tmp_path):
|
||||
(tmp_path / "a.txt").write_bytes(b"aaa")
|
||||
(tmp_path / "b.txt").write_bytes(b"bb")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await du(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"))
|
||||
assert result == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_all_returns_pairs(tmp_path):
|
||||
(tmp_path / "a.txt").write_bytes(b"aaa")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_bytes(b"bb")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
entries, total = await du_all(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"))
|
||||
paths = [p for p, _ in entries]
|
||||
assert "/a.txt" in paths
|
||||
assert "/sub/b.txt" in paths
|
||||
assert total == 5
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.core.disk.find import find
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_all_files(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("b")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await find(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"))
|
||||
assert "/a.txt" in result
|
||||
assert "/sub/b.txt" in result
|
||||
assert "/sub" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_name_pattern(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "b.py").write_text("b")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await find(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
name="*.txt")
|
||||
assert result == ["/a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_type_filter_file(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "sub").mkdir()
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await find(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
type="f")
|
||||
assert "/a.txt" in result
|
||||
assert "/sub" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_type_filter_directory(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "sub").mkdir()
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await find(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
type="d")
|
||||
assert "/sub" in result
|
||||
assert "/a.txt" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_maxdepth(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.txt").write_text("b")
|
||||
(tmp_path / "sub" / "deep").mkdir()
|
||||
(tmp_path / "sub" / "deep" / "c.txt").write_text("c")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
result = await find(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
maxdepth=1)
|
||||
assert "/a.txt" in result
|
||||
assert "/sub" in result
|
||||
assert "/sub/b.txt" not in result
|
||||
assert "/sub/deep/c.txt" not in result
|
||||
@@ -0,0 +1,65 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.disk.glob import resolve_glob
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_file_path(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path="a.txt",
|
||||
virtual="/a.txt",
|
||||
directory="/",
|
||||
resolved=True)
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
assert len(result) == 1
|
||||
assert result[0].virtual == "/a.txt"
|
||||
assert result[0].resolved is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_glob_pattern(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "b.txt").write_text("b")
|
||||
(tmp_path / "c.py").write_text("c")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path="*.txt",
|
||||
virtual="/*.txt",
|
||||
directory="/",
|
||||
pattern="*.txt",
|
||||
resolved=False)
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
originals = sorted(r.virtual for r in result)
|
||||
assert originals == ["/a.txt", "/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_directory_path(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path="",
|
||||
virtual="/",
|
||||
directory="/",
|
||||
resolved=False)
|
||||
result = await resolve_glob(accessor, [scope], index)
|
||||
assert len(result) == 1
|
||||
assert result[0].virtual == "/"
|
||||
@@ -0,0 +1,58 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.disk.read import read_bytes
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file(tmp_path):
|
||||
(tmp_path / "hello.txt").write_bytes(b"hello world")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await read_bytes(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/hello.txt", "/disk"),
|
||||
virtual="/hello.txt",
|
||||
directory="/hello.txt"), index)
|
||||
assert result == b"hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_not_found(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read_bytes(
|
||||
accessor,
|
||||
PathSpec(resource_path="missing.txt",
|
||||
virtual="/missing.txt",
|
||||
directory="/missing.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_with_glob_scope_and_prefix(tmp_path):
|
||||
(tmp_path / "data.bin").write_bytes(b"\x00\x01\x02")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path=mount_key("/disk/data.bin", "/disk"),
|
||||
virtual="/disk/data.bin",
|
||||
directory="/disk/")
|
||||
result = await read_bytes(accessor, scope, index)
|
||||
assert result == b"\x00\x01\x02"
|
||||
@@ -0,0 +1,140 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.disk.readdir import readdir
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_directory(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/", "/disk"),
|
||||
virtual="/",
|
||||
directory="/"), index)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_with_files(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
(tmp_path / "b.txt").write_text("b")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result == ["/a.txt", "/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_with_subdirectories(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "file.txt").write_text("x")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result == ["/file.txt", "/sub"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=600)
|
||||
first = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
(tmp_path / "b.txt").write_text("b")
|
||||
second = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert first == second
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_prefix(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/disk/", "/disk"),
|
||||
virtual="/disk/",
|
||||
directory="/disk/"), index)
|
||||
assert result == ["/disk/a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_glob_scope(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path=mount_key("/disk/", "/disk"),
|
||||
virtual="/disk/",
|
||||
directory="/disk/")
|
||||
result = await readdir(accessor, scope, index)
|
||||
assert result == ["/disk/a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_a_directory(tmp_path):
|
||||
(tmp_path / "file.txt").write_text("x")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
with pytest.raises(NotADirectoryError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="file.txt",
|
||||
virtual="/file.txt",
|
||||
directory="/file.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_entries_stay_clean(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=600)
|
||||
spec = PathSpec(resource_path=mount_key("/data/", "/data"),
|
||||
virtual="/data/",
|
||||
directory="/data/")
|
||||
cold = await readdir(accessor, spec, index)
|
||||
warm = await readdir(accessor, spec, index)
|
||||
assert cold == ["/data/a.txt"]
|
||||
assert warm == cold
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_key_ignores_trailing_slash(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("a")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=600)
|
||||
slashed = PathSpec(resource_path=mount_key("/data/", "/data"),
|
||||
virtual="/data/",
|
||||
directory="/data/")
|
||||
bare = PathSpec(resource_path=mount_key("/data", "/data"),
|
||||
virtual="/data",
|
||||
directory="/data")
|
||||
first = await readdir(accessor, slashed, index)
|
||||
second = await readdir(accessor, bare, index)
|
||||
assert first == second == ["/data/a.txt"]
|
||||
@@ -0,0 +1,90 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.disk.stat import stat
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file(tmp_path):
|
||||
(tmp_path / "hello.txt").write_text("hello")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/hello.txt", "/disk"),
|
||||
virtual="/hello.txt",
|
||||
directory="/hello.txt"), index)
|
||||
assert result.name == "hello.txt"
|
||||
assert result.size == 5
|
||||
assert result.modified is not None
|
||||
assert result.modified.endswith("Z")
|
||||
assert "+00:00" not in result.modified
|
||||
assert result.type != FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory(tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="sub", virtual="/sub", directory="/sub"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.size is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file_not_found(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="missing.txt",
|
||||
virtual="/missing.txt",
|
||||
directory="/missing.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_with_glob_scope(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("data")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path=mount_key("/disk/a.txt", "/disk"),
|
||||
virtual="/disk/a.txt",
|
||||
directory="/disk/")
|
||||
result = await stat(accessor, scope, index)
|
||||
assert result.name == "a.txt"
|
||||
assert result.size == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_with_prefix(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("data")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/disk/a.txt", "/disk"),
|
||||
virtual="/disk/a.txt",
|
||||
directory="/disk/a.txt"), index)
|
||||
assert result.name == "a.txt"
|
||||
assert result.size == 4
|
||||
@@ -0,0 +1,29 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.core.disk.stat import stat
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
def test_disk_stat_returns_fingerprint_from_mtime(tmp_path):
|
||||
p = tmp_path / "f.txt"
|
||||
p.write_bytes(b"hi")
|
||||
accessor = DiskAccessor(root=tmp_path)
|
||||
scope = PathSpec(resource_path="f.txt", virtual="/f.txt", directory="/")
|
||||
result = asyncio.run(stat(accessor, scope))
|
||||
assert result.fingerprint is not None
|
||||
assert result.fingerprint == result.modified
|
||||
@@ -0,0 +1,50 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.disk.stream import read_stream
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_file(tmp_path):
|
||||
(tmp_path / "data.txt").write_bytes(b"stream content")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
chunks = []
|
||||
async for chunk in read_stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="data.txt",
|
||||
virtual="/data.txt",
|
||||
directory="/data.txt"), index):
|
||||
chunks.append(chunk)
|
||||
assert b"".join(chunks) == b"stream content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_glob_scope(tmp_path):
|
||||
(tmp_path / "data.txt").write_bytes(b"abc")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
index = RAMIndexCacheStore(ttl=0)
|
||||
scope = PathSpec(resource_path=mount_key("/disk/data.txt", "/disk"),
|
||||
virtual="/disk/data.txt",
|
||||
directory="/disk/")
|
||||
chunks = []
|
||||
async for chunk in read_stream(accessor, scope, index):
|
||||
chunks.append(chunk)
|
||||
assert b"".join(chunks) == b"abc"
|
||||
@@ -0,0 +1,53 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.core.disk.write import write_bytes
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_new_file(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
await write_bytes(
|
||||
accessor,
|
||||
PathSpec(resource_path="new.txt",
|
||||
virtual="/new.txt",
|
||||
directory="/new.txt"), b"content")
|
||||
assert (tmp_path / "new.txt").read_bytes() == b"content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_existing_file(tmp_path):
|
||||
(tmp_path / "exist.txt").write_bytes(b"old")
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
await write_bytes(
|
||||
accessor,
|
||||
PathSpec(resource_path="exist.txt",
|
||||
virtual="/exist.txt",
|
||||
directory="/exist.txt"), b"new")
|
||||
assert (tmp_path / "exist.txt").read_bytes() == b"new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parent_directory_auto_creation(tmp_path):
|
||||
accessor = DiskAccessor(tmp_path)
|
||||
await write_bytes(
|
||||
accessor,
|
||||
PathSpec(resource_path="a/b/c/file.txt",
|
||||
virtual="/a/b/c/file.txt",
|
||||
directory="/a/b/c/file.txt"), b"deep")
|
||||
assert (tmp_path / "a" / "b" / "c" / "file.txt").read_bytes() == b"deep"
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.email import EmailAccessor
|
||||
from mirage.core.email._client import list_folders, list_message_uids
|
||||
from mirage.resource.email.config import EmailConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return EmailConfig(
|
||||
imap_host="imap.test.com",
|
||||
smtp_host="smtp.test.com",
|
||||
username="user@test.com",
|
||||
password="pass",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config):
|
||||
return EmailAccessor(config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders(accessor):
|
||||
mock_imap = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.lines = [
|
||||
b'(\\HasNoChildren) "/" "INBOX"',
|
||||
b'(\\HasNoChildren) "/" "Sent"',
|
||||
b'(\\HasNoChildren) "/" "Drafts"',
|
||||
]
|
||||
mock_imap.list.return_value = mock_response
|
||||
accessor._imap = mock_imap
|
||||
accessor._imap.protocol = True
|
||||
|
||||
folders = await list_folders(accessor)
|
||||
assert "INBOX" in folders
|
||||
assert "Sent" in folders
|
||||
assert "Drafts" in folders
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_message_uids(accessor):
|
||||
mock_imap = AsyncMock()
|
||||
mock_select_response = MagicMock()
|
||||
mock_select_response.result = "OK"
|
||||
mock_imap.select.return_value = mock_select_response
|
||||
|
||||
mock_search_response = MagicMock()
|
||||
mock_search_response.result = "OK"
|
||||
mock_search_response.lines = [b"1 2 3 4 5"]
|
||||
mock_imap.search.return_value = mock_search_response
|
||||
|
||||
mock_fetch_response = MagicMock()
|
||||
mock_fetch_response.lines = [
|
||||
b"1 FETCH (UID 101)",
|
||||
b"2 FETCH (UID 102)",
|
||||
b"3 FETCH (UID 103)",
|
||||
b"4 FETCH (UID 104)",
|
||||
b"5 FETCH (UID 105)",
|
||||
b"FETCH completed",
|
||||
]
|
||||
mock_imap.fetch.return_value = mock_fetch_response
|
||||
accessor._imap = mock_imap
|
||||
accessor._imap.protocol = True
|
||||
|
||||
uids = await list_message_uids(accessor, "INBOX")
|
||||
assert uids == ["101", "102", "103", "104", "105"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_message_uids_empty(accessor):
|
||||
mock_imap = AsyncMock()
|
||||
mock_select_response = MagicMock()
|
||||
mock_select_response.result = "OK"
|
||||
mock_imap.select.return_value = mock_select_response
|
||||
|
||||
mock_search_response = MagicMock()
|
||||
mock_search_response.result = "OK"
|
||||
mock_search_response.lines = [b""]
|
||||
mock_imap.search.return_value = mock_search_response
|
||||
accessor._imap = mock_imap
|
||||
accessor._imap.protocol = True
|
||||
|
||||
uids = await list_message_uids(accessor, "INBOX")
|
||||
assert uids == []
|
||||
@@ -0,0 +1,99 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from mirage.core.email._parse import parse_rfc822
|
||||
|
||||
|
||||
def test_parse_simple_text_email():
|
||||
raw = (b"From: Alice <alice@example.com>\r\n"
|
||||
b"To: Bob <bob@example.com>\r\n"
|
||||
b"Subject: Hello\r\n"
|
||||
b"Date: Mon, 14 Apr 2026 10:30:00 +0000\r\n"
|
||||
b"Message-ID: <abc123@example.com>\r\n"
|
||||
b"\r\n"
|
||||
b"Hello, world!")
|
||||
result = parse_rfc822(raw)
|
||||
assert result["from"] == {"name": "Alice", "email": "alice@example.com"}
|
||||
assert result["to"] == [{"name": "Bob", "email": "bob@example.com"}]
|
||||
assert result["subject"] == "Hello"
|
||||
assert result["body_text"] == "Hello, world!"
|
||||
assert result["message_id"] == "<abc123@example.com>"
|
||||
assert result["has_attachments"] is False
|
||||
|
||||
|
||||
def test_parse_multipart_email():
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = "Alice <alice@example.com>"
|
||||
msg["To"] = "Bob <bob@example.com>"
|
||||
msg["Subject"] = "With HTML"
|
||||
msg["Message-ID"] = "<def456@example.com>"
|
||||
msg.attach(MIMEText("Plain text body", "plain"))
|
||||
msg.attach(MIMEText("<p>HTML body</p>", "html"))
|
||||
raw = msg.as_bytes()
|
||||
|
||||
result = parse_rfc822(raw)
|
||||
assert result["body_text"] == "Plain text body"
|
||||
assert result["body_html"] == "<p>HTML body</p>"
|
||||
assert result["has_attachments"] is False
|
||||
|
||||
|
||||
def test_parse_headers_only():
|
||||
raw = (b"From: Alice <alice@example.com>\r\n"
|
||||
b"Subject: Test\r\n"
|
||||
b"\r\n"
|
||||
b"Body text here")
|
||||
result = parse_rfc822(raw, headers_only=True)
|
||||
assert result["subject"] == "Test"
|
||||
assert result["body_text"] == ""
|
||||
|
||||
|
||||
def test_parse_reply_headers():
|
||||
raw = (b"From: Bob <bob@example.com>\r\n"
|
||||
b"To: Alice <alice@example.com>\r\n"
|
||||
b"Subject: Re: Hello\r\n"
|
||||
b"In-Reply-To: <abc123@example.com>\r\n"
|
||||
b"References: <abc123@example.com> <def456@example.com>\r\n"
|
||||
b"\r\n"
|
||||
b"Thanks!")
|
||||
result = parse_rfc822(raw)
|
||||
assert result["in_reply_to"] == "<abc123@example.com>"
|
||||
assert result["references"] == [
|
||||
"<abc123@example.com>", "<def456@example.com>"
|
||||
]
|
||||
|
||||
|
||||
def test_parse_address_without_name():
|
||||
raw = (b"From: alice@example.com\r\n"
|
||||
b"To: bob@example.com\r\n"
|
||||
b"Subject: Test\r\n"
|
||||
b"\r\n"
|
||||
b"Body")
|
||||
result = parse_rfc822(raw)
|
||||
assert result["from"]["email"] == "alice@example.com"
|
||||
assert result["from"]["name"] == ""
|
||||
|
||||
|
||||
def test_parse_cc():
|
||||
raw = (b"From: alice@example.com\r\n"
|
||||
b"To: bob@example.com\r\n"
|
||||
b"Cc: Charlie <charlie@example.com>, dave@example.com\r\n"
|
||||
b"Subject: Test\r\n"
|
||||
b"\r\n"
|
||||
b"Body")
|
||||
result = parse_rfc822(raw)
|
||||
assert len(result["cc"]) == 2
|
||||
assert result["cc"][0]["email"] == "charlie@example.com"
|
||||
@@ -0,0 +1,92 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.email.send import forward_message, reply_message, send_message
|
||||
from mirage.resource.email.config import EmailConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return EmailConfig(
|
||||
imap_host="imap.test.com",
|
||||
smtp_host="smtp.test.com",
|
||||
smtp_port=587,
|
||||
username="user@test.com",
|
||||
password="pass",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message(config):
|
||||
with patch("mirage.core.email.send.aiosmtplib.send",
|
||||
new_callable=AsyncMock) as mock_send:
|
||||
result = await send_message(config, "bob@example.com", "Hello",
|
||||
"Hi there")
|
||||
assert result["status"] == "sent"
|
||||
assert result["to"] == "bob@example.com"
|
||||
assert result["subject"] == "Hello"
|
||||
mock_send.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_message(config):
|
||||
original = {
|
||||
"from": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"to": [{
|
||||
"name": "",
|
||||
"email": "user@test.com"
|
||||
}],
|
||||
"cc": [],
|
||||
"subject": "Hello",
|
||||
"message_id": "<abc123@example.com>",
|
||||
"references": [],
|
||||
"body_text": "Original text",
|
||||
"date": "Mon, 14 Apr 2026 10:30:00 +0000",
|
||||
}
|
||||
with patch("mirage.core.email.send.aiosmtplib.send",
|
||||
new_callable=AsyncMock) as mock_send:
|
||||
result = await reply_message(config, original, "Thanks!")
|
||||
assert result["status"] == "sent"
|
||||
assert result["to"] == "alice@example.com"
|
||||
assert result["subject"] == "Re: Hello"
|
||||
mock_send.assert_called_once()
|
||||
sent_msg = mock_send.call_args[0][0]
|
||||
assert sent_msg["In-Reply-To"] == "<abc123@example.com>"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_message(config):
|
||||
original = {
|
||||
"from": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"subject": "Hello",
|
||||
"body_text": "Original text",
|
||||
"date": "Mon, 14 Apr 2026 10:30:00 +0000",
|
||||
}
|
||||
with patch("mirage.core.email.send.aiosmtplib.send",
|
||||
new_callable=AsyncMock) as mock_send:
|
||||
result = await forward_message(config, original, "charlie@example.com")
|
||||
assert result["status"] == "sent"
|
||||
assert result["to"] == "charlie@example.com"
|
||||
assert result["subject"] == "Fwd: Hello"
|
||||
mock_send.assert_called_once()
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,171 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import io
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.feather as feather
|
||||
import pytest
|
||||
|
||||
from mirage.core.filetype.feather import cat, cut, grep, head, stat, tail, wc
|
||||
|
||||
|
||||
def _make_feather(num_rows: int = 100) -> bytes:
|
||||
df = pd.DataFrame({
|
||||
"name": [f"user{i}" for i in range(num_rows)],
|
||||
"score":
|
||||
list(range(num_rows)),
|
||||
"grade": ["A" if i % 2 == 0 else "B" for i in range(num_rows)],
|
||||
})
|
||||
table = pa.Table.from_pandas(df)
|
||||
buf = io.BytesIO()
|
||||
feather.write_feather(table, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
FEATHER_BYTES = _make_feather()
|
||||
SMALL_FEATHER = _make_feather(num_rows=5)
|
||||
|
||||
|
||||
class TestCat:
|
||||
|
||||
def test_cat_returns_schema_and_preview(self):
|
||||
result = cat(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
assert "user0" in text
|
||||
|
||||
def test_cat_limits_preview_rows(self):
|
||||
result = cat(FEATHER_BYTES, max_rows=5)
|
||||
text = result.decode()
|
||||
assert "user4" in text
|
||||
assert "user5" not in text
|
||||
|
||||
def test_cat_small_file(self):
|
||||
result = cat(SMALL_FEATHER)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user4" in text
|
||||
|
||||
|
||||
class TestHead:
|
||||
|
||||
def test_head_default_10_rows(self):
|
||||
result = head(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user9" in text
|
||||
|
||||
def test_head_custom_n(self):
|
||||
result = head(FEATHER_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user2" in text
|
||||
assert "user3" not in text
|
||||
|
||||
def test_head_includes_schema(self):
|
||||
result = head(FEATHER_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestTail:
|
||||
|
||||
def test_tail_default_10_rows(self):
|
||||
result = tail(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user90" in text
|
||||
|
||||
def test_tail_custom_n(self):
|
||||
result = tail(FEATHER_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user97" in text
|
||||
assert "user96" not in text
|
||||
|
||||
def test_tail_includes_schema(self):
|
||||
result = tail(FEATHER_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestWc:
|
||||
|
||||
def test_wc_returns_row_count(self):
|
||||
result = wc(FEATHER_BYTES)
|
||||
assert result == 100
|
||||
|
||||
def test_wc_small(self):
|
||||
result = wc(SMALL_FEATHER)
|
||||
assert result == 5
|
||||
|
||||
|
||||
class TestStat:
|
||||
|
||||
def test_stat_includes_schema(self):
|
||||
result = stat(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
|
||||
def test_stat_includes_row_count(self):
|
||||
result = stat(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "100" in text
|
||||
|
||||
def test_stat_header(self):
|
||||
result = stat(FEATHER_BYTES)
|
||||
text = result.decode()
|
||||
assert "Feather file" in text
|
||||
|
||||
|
||||
class TestGrep:
|
||||
|
||||
def test_grep_finds_matching_rows(self):
|
||||
result = grep(FEATHER_BYTES, "user1")
|
||||
text = result.decode()
|
||||
assert "user1," in text or "user1" in text
|
||||
|
||||
def test_grep_case_insensitive(self):
|
||||
result = grep(FEATHER_BYTES, "USER1", ignore_case=True)
|
||||
text = result.decode()
|
||||
assert "user1" in text
|
||||
|
||||
def test_grep_no_match(self):
|
||||
result = grep(FEATHER_BYTES, "nonexistent")
|
||||
text = result.decode()
|
||||
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||||
assert len(lines) <= 1
|
||||
|
||||
|
||||
class TestCut:
|
||||
|
||||
def test_cut_single_column(self):
|
||||
result = cut(FEATHER_BYTES, columns=["name"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_multiple_columns(self):
|
||||
result = cut(FEATHER_BYTES, columns=["name", "grade"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "grade" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_invalid_column(self):
|
||||
with pytest.raises(ValueError):
|
||||
cut(FEATHER_BYTES, columns=["nonexistent"])
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import tempfile
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from mirage.core.filetype.hdf5 import cat, cut, grep, head, stat, tail, wc
|
||||
|
||||
|
||||
def _make_hdf5(num_rows: int = 100) -> bytes:
|
||||
df = pd.DataFrame({
|
||||
"name": [f"user{i}" for i in range(num_rows)],
|
||||
"score":
|
||||
list(range(num_rows)),
|
||||
"grade": ["A" if i % 2 == 0 else "B" for i in range(num_rows)],
|
||||
})
|
||||
with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
|
||||
df.to_hdf(f.name, key="data", mode="w")
|
||||
tmp = f.name
|
||||
with open(tmp, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
HDF5_BYTES = _make_hdf5()
|
||||
SMALL_HDF5 = _make_hdf5(num_rows=5)
|
||||
|
||||
|
||||
class TestCat:
|
||||
|
||||
def test_cat_returns_preview(self):
|
||||
result = cat(HDF5_BYTES)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
|
||||
def test_cat_limits_preview_rows(self):
|
||||
result = cat(HDF5_BYTES, max_rows=5)
|
||||
text = result.decode()
|
||||
assert "user4" in text
|
||||
assert "user5" not in text
|
||||
|
||||
|
||||
class TestHead:
|
||||
|
||||
def test_head_custom_n(self):
|
||||
result = head(HDF5_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user2" in text
|
||||
assert "user3" not in text
|
||||
|
||||
|
||||
class TestTail:
|
||||
|
||||
def test_tail_custom_n(self):
|
||||
result = tail(HDF5_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user96" not in text
|
||||
|
||||
|
||||
class TestWc:
|
||||
|
||||
def test_wc_returns_row_count(self):
|
||||
assert wc(HDF5_BYTES) == 100
|
||||
|
||||
|
||||
class TestStat:
|
||||
|
||||
def test_stat_includes_row_count(self):
|
||||
result = stat(HDF5_BYTES)
|
||||
text = result.decode()
|
||||
assert "100" in text
|
||||
|
||||
|
||||
class TestGrep:
|
||||
|
||||
def test_grep_finds_matching_rows(self):
|
||||
result = grep(HDF5_BYTES, "user1")
|
||||
text = result.decode()
|
||||
assert "user1" in text
|
||||
|
||||
|
||||
class TestCut:
|
||||
|
||||
def test_cut_single_column(self):
|
||||
result = cut(HDF5_BYTES, columns=["name"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_invalid_column(self):
|
||||
with pytest.raises(ValueError):
|
||||
cut(HDF5_BYTES, columns=["nonexistent"])
|
||||
@@ -0,0 +1,171 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import io
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.orc as orc
|
||||
import pytest
|
||||
|
||||
from mirage.core.filetype.orc import cat, cut, grep, head, stat, tail, wc
|
||||
|
||||
|
||||
def _make_orc(num_rows: int = 100) -> bytes:
|
||||
df = pd.DataFrame({
|
||||
"name": [f"user{i}" for i in range(num_rows)],
|
||||
"score":
|
||||
list(range(num_rows)),
|
||||
"grade": ["A" if i % 2 == 0 else "B" for i in range(num_rows)],
|
||||
})
|
||||
table = pa.Table.from_pandas(df)
|
||||
buf = io.BytesIO()
|
||||
orc.write_table(table, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
ORC_BYTES = _make_orc()
|
||||
SMALL_ORC = _make_orc(num_rows=5)
|
||||
|
||||
|
||||
class TestCat:
|
||||
|
||||
def test_cat_returns_schema_and_preview(self):
|
||||
result = cat(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
assert "user0" in text
|
||||
|
||||
def test_cat_limits_preview_rows(self):
|
||||
result = cat(ORC_BYTES, max_rows=5)
|
||||
text = result.decode()
|
||||
assert "user4" in text
|
||||
assert "user5" not in text
|
||||
|
||||
def test_cat_small_file(self):
|
||||
result = cat(SMALL_ORC)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user4" in text
|
||||
|
||||
|
||||
class TestHead:
|
||||
|
||||
def test_head_default_10_rows(self):
|
||||
result = head(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user9" in text
|
||||
|
||||
def test_head_custom_n(self):
|
||||
result = head(ORC_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user2" in text
|
||||
assert "user3" not in text
|
||||
|
||||
def test_head_includes_schema(self):
|
||||
result = head(ORC_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestTail:
|
||||
|
||||
def test_tail_default_10_rows(self):
|
||||
result = tail(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user90" in text
|
||||
|
||||
def test_tail_custom_n(self):
|
||||
result = tail(ORC_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user97" in text
|
||||
assert "user96" not in text
|
||||
|
||||
def test_tail_includes_schema(self):
|
||||
result = tail(ORC_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestWc:
|
||||
|
||||
def test_wc_returns_row_count(self):
|
||||
result = wc(ORC_BYTES)
|
||||
assert result == 100
|
||||
|
||||
def test_wc_small(self):
|
||||
result = wc(SMALL_ORC)
|
||||
assert result == 5
|
||||
|
||||
|
||||
class TestStat:
|
||||
|
||||
def test_stat_includes_schema(self):
|
||||
result = stat(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
|
||||
def test_stat_includes_row_count(self):
|
||||
result = stat(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "100" in text
|
||||
|
||||
def test_stat_includes_stripes(self):
|
||||
result = stat(ORC_BYTES)
|
||||
text = result.decode()
|
||||
assert "stripes:" in text
|
||||
|
||||
|
||||
class TestGrep:
|
||||
|
||||
def test_grep_finds_matching_rows(self):
|
||||
result = grep(ORC_BYTES, "user1")
|
||||
text = result.decode()
|
||||
assert "user1," in text or "user1" in text
|
||||
|
||||
def test_grep_case_insensitive(self):
|
||||
result = grep(ORC_BYTES, "USER1", ignore_case=True)
|
||||
text = result.decode()
|
||||
assert "user1" in text
|
||||
|
||||
def test_grep_no_match(self):
|
||||
result = grep(ORC_BYTES, "nonexistent")
|
||||
text = result.decode()
|
||||
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||||
assert len(lines) <= 1
|
||||
|
||||
|
||||
class TestCut:
|
||||
|
||||
def test_cut_single_column(self):
|
||||
result = cut(ORC_BYTES, columns=["name"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_multiple_columns(self):
|
||||
result = cut(ORC_BYTES, columns=["name", "grade"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "grade" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_invalid_column(self):
|
||||
with pytest.raises(ValueError):
|
||||
cut(ORC_BYTES, columns=["nonexistent"])
|
||||
@@ -0,0 +1,171 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import io
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
import pytest
|
||||
|
||||
from mirage.core.filetype.parquet import cat, cut, grep, head, stat, tail, wc
|
||||
|
||||
|
||||
def _make_parquet(num_rows: int = 100, row_group_size: int = 50) -> bytes:
|
||||
df = pd.DataFrame({
|
||||
"name": [f"user{i}" for i in range(num_rows)],
|
||||
"score":
|
||||
list(range(num_rows)),
|
||||
"grade": ["A" if i % 2 == 0 else "B" for i in range(num_rows)],
|
||||
})
|
||||
table = pa.Table.from_pandas(df)
|
||||
buf = io.BytesIO()
|
||||
pq.write_table(table, buf, row_group_size=row_group_size)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
PARQUET_BYTES = _make_parquet()
|
||||
SMALL_PARQUET = _make_parquet(num_rows=5, row_group_size=5)
|
||||
|
||||
|
||||
class TestCat:
|
||||
|
||||
def test_cat_returns_schema_and_preview(self):
|
||||
result = cat(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
assert "user0" in text
|
||||
|
||||
def test_cat_limits_preview_rows(self):
|
||||
result = cat(PARQUET_BYTES, max_rows=5)
|
||||
text = result.decode()
|
||||
assert "user4" in text
|
||||
assert "user5" not in text
|
||||
|
||||
def test_cat_small_file(self):
|
||||
result = cat(SMALL_PARQUET)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user4" in text
|
||||
|
||||
|
||||
class TestHead:
|
||||
|
||||
def test_head_default_10_rows(self):
|
||||
result = head(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "user0" in text
|
||||
assert "user9" in text
|
||||
|
||||
def test_head_custom_n(self):
|
||||
result = head(PARQUET_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user2" in text
|
||||
assert "user3" not in text
|
||||
|
||||
def test_head_includes_schema(self):
|
||||
result = head(PARQUET_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestTail:
|
||||
|
||||
def test_tail_default_10_rows(self):
|
||||
result = tail(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user90" in text
|
||||
|
||||
def test_tail_custom_n(self):
|
||||
result = tail(PARQUET_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "user99" in text
|
||||
assert "user97" in text
|
||||
assert "user96" not in text
|
||||
|
||||
def test_tail_includes_schema(self):
|
||||
result = tail(PARQUET_BYTES, n=3)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
|
||||
|
||||
class TestWc:
|
||||
|
||||
def test_wc_returns_row_count(self):
|
||||
result = wc(PARQUET_BYTES)
|
||||
assert result == 100
|
||||
|
||||
def test_wc_small(self):
|
||||
result = wc(SMALL_PARQUET)
|
||||
assert result == 5
|
||||
|
||||
|
||||
class TestStat:
|
||||
|
||||
def test_stat_includes_schema(self):
|
||||
result = stat(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "name:" in text
|
||||
assert "score: int64" in text
|
||||
|
||||
def test_stat_includes_row_count(self):
|
||||
result = stat(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "100" in text
|
||||
|
||||
def test_stat_includes_row_groups(self):
|
||||
result = stat(PARQUET_BYTES)
|
||||
text = result.decode()
|
||||
assert "row_groups: 2" in text
|
||||
|
||||
|
||||
class TestGrep:
|
||||
|
||||
def test_grep_finds_matching_rows(self):
|
||||
result = grep(PARQUET_BYTES, "user1")
|
||||
text = result.decode()
|
||||
assert "user1," in text or "user1" in text
|
||||
|
||||
def test_grep_case_insensitive(self):
|
||||
result = grep(PARQUET_BYTES, "USER1", ignore_case=True)
|
||||
text = result.decode()
|
||||
assert "user1" in text
|
||||
|
||||
def test_grep_no_match(self):
|
||||
result = grep(PARQUET_BYTES, "nonexistent")
|
||||
text = result.decode()
|
||||
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||||
assert len(lines) <= 1
|
||||
|
||||
|
||||
class TestCut:
|
||||
|
||||
def test_cut_single_column(self):
|
||||
result = cut(PARQUET_BYTES, columns=["name"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_multiple_columns(self):
|
||||
result = cut(PARQUET_BYTES, columns=["name", "grade"])
|
||||
text = result.decode()
|
||||
assert "name" in text
|
||||
assert "grade" in text
|
||||
assert "score" not in text
|
||||
|
||||
def test_cut_invalid_column(self):
|
||||
with pytest.raises(ValueError):
|
||||
cut(PARQUET_BYTES, columns=["nonexistent"])
|
||||
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.filetype.pdf import cat, pages_to_images
|
||||
|
||||
_EXAMPLE_PDF = Path(__file__).resolve().parents[4] / "data" / "example.pdf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_bytes():
|
||||
with open(_EXAMPLE_PDF, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_pages_to_images_returns_pngs(pdf_bytes):
|
||||
images = pages_to_images(pdf_bytes, max_pages=3)
|
||||
assert len(images) == 3
|
||||
for page_num, png_bytes in images:
|
||||
assert isinstance(page_num, int)
|
||||
assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_pages_to_images_page_numbers(pdf_bytes):
|
||||
images = pages_to_images(pdf_bytes, max_pages=2)
|
||||
assert images[0][0] == 1
|
||||
assert images[1][0] == 2
|
||||
|
||||
|
||||
def test_pages_to_images_max_pages(pdf_bytes):
|
||||
images = pages_to_images(pdf_bytes, max_pages=2)
|
||||
assert len(images) == 2
|
||||
|
||||
|
||||
def test_cat_returns_text(pdf_bytes):
|
||||
result = cat(pdf_bytes, max_pages=2)
|
||||
assert isinstance(result, bytes)
|
||||
text = result.decode()
|
||||
assert "# PDF: 15 pages" in text
|
||||
assert "## Page 1" in text
|
||||
assert "## Page 2" in text
|
||||
|
||||
|
||||
def test_cat_includes_text_content(pdf_bytes):
|
||||
result = cat(pdf_bytes, max_pages=1)
|
||||
text = result.decode()
|
||||
assert "SEAR" in text or "Schema" in text
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,90 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.google._client import TokenManager, google_headers
|
||||
from mirage.core.google.config import GoogleConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GoogleConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_manager_refreshes_on_first_call(config):
|
||||
mgr = TokenManager(config)
|
||||
with patch(
|
||||
"mirage.core.google._client.refresh_access_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("new-token", 3600),
|
||||
) as mock_refresh:
|
||||
token = await mgr.get_token()
|
||||
assert token == "new-token"
|
||||
mock_refresh.assert_called_once_with(config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_manager_caches_token(config):
|
||||
mgr = TokenManager(config)
|
||||
with patch(
|
||||
"mirage.core.google._client.refresh_access_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("cached-token", 3600),
|
||||
) as mock_refresh:
|
||||
t1 = await mgr.get_token()
|
||||
t2 = await mgr.get_token()
|
||||
assert t1 == t2 == "cached-token"
|
||||
assert mock_refresh.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_manager_refreshes_when_expired(config):
|
||||
mgr = TokenManager(config)
|
||||
with patch(
|
||||
"mirage.core.google._client.refresh_access_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("token-1", 3600),
|
||||
):
|
||||
await mgr.get_token()
|
||||
|
||||
mgr._expires_at = time.time() - 1
|
||||
|
||||
with patch(
|
||||
"mirage.core.google._client.refresh_access_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("token-2", 3600),
|
||||
):
|
||||
token = await mgr.get_token()
|
||||
assert token == "token-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_headers(config):
|
||||
mgr = TokenManager(config)
|
||||
with patch(
|
||||
"mirage.core.google._client.refresh_access_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("my-token", 3600),
|
||||
):
|
||||
headers = await google_headers(mgr)
|
||||
assert headers["Authorization"] == "Bearer my-token"
|
||||
@@ -0,0 +1,171 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdocs import GDocsAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdocs._client import TokenManager
|
||||
from mirage.core.gdocs.read import read, read_doc
|
||||
from mirage.resource.gdocs.config import GDocsConfig
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager():
|
||||
config = GDocsConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(token_manager):
|
||||
return GDocsAccessor(config=None, token_manager=token_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_doc(token_manager):
|
||||
doc_json = {
|
||||
"documentId": "abc123",
|
||||
"title": "Test Doc",
|
||||
"body": {
|
||||
"content": []
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.gdocs.read.google_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=doc_json,
|
||||
):
|
||||
result = await read_doc(token_manager, "abc123")
|
||||
parsed = json.loads(result)
|
||||
assert parsed["documentId"] == "abc123"
|
||||
assert parsed["title"] == "Test Doc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_via_index(accessor, index):
|
||||
await index.set_dir("/gdocs/owned", [
|
||||
("2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
IndexEntry(id="abc123",
|
||||
name="Test Doc",
|
||||
resource_type="gdocs/file",
|
||||
vfs_name="2026-04-01_My_Doc__doc1.gdoc.json")),
|
||||
])
|
||||
doc_json = {
|
||||
"documentId": "abc123",
|
||||
"title": "Test Doc",
|
||||
"body": {
|
||||
"content": []
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.gdocs.read.google_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=doc_json,
|
||||
):
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
directory="/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json"),
|
||||
index)
|
||||
parsed = json.loads(result)
|
||||
assert parsed["documentId"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_no_index(accessor):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/gdocs/owned/nonexistent.gdoc.json", "/gdocs"),
|
||||
virtual="/gdocs/owned/nonexistent.gdoc.json",
|
||||
directory="/gdocs/owned/nonexistent.gdoc.json"), None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_auto_bootstraps_from_empty_index(accessor, index):
|
||||
files = [{
|
||||
"id": "doc1",
|
||||
"name": "Notes",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}],
|
||||
}]
|
||||
with (
|
||||
patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
),
|
||||
patch(
|
||||
"mirage.core.gdocs.read.read_doc",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b'{"documentId":"doc1"}',
|
||||
),
|
||||
):
|
||||
path = PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/gdocs/owned/2026-04-01_Notes__doc1.gdoc.json", "/gdocs"),
|
||||
virtual="/gdocs/owned/2026-04-01_Notes__doc1.gdoc.json",
|
||||
directory="/gdocs/owned/2026-04-01_Notes__doc1.gdoc.json",
|
||||
)
|
||||
result = await read(accessor, path, index)
|
||||
assert b"doc1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_missing_file_raises_after_recursion(accessor, index):
|
||||
with (
|
||||
patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"mirage.core.gdocs.read.read_doc",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=AssertionError("should not call read_doc"),
|
||||
),
|
||||
):
|
||||
path = PathSpec(
|
||||
resource_path=mount_key("/gdocs/owned/Missing__xyz.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/Missing__xyz.gdoc.json",
|
||||
directory="/gdocs/owned/Missing__xyz.gdoc.json",
|
||||
)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor, path, index)
|
||||
@@ -0,0 +1,363 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdocs import GDocsAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdocs.readdir import readdir
|
||||
from mirage.core.gdocs.stat import stat
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return GDocsAccessor(config=None, token_manager=None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs", "/gdocs"),
|
||||
virtual="/gdocs",
|
||||
directory="/gdocs"), index)
|
||||
assert result == ["/gdocs/owned", "/gdocs/shared"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned(accessor, index):
|
||||
files = [
|
||||
{
|
||||
"id": "doc1",
|
||||
"name": "My Doc",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}],
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
assert len(result) == 1
|
||||
assert "doc1" in result[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_shared(accessor, index):
|
||||
files = [
|
||||
{
|
||||
"id": "doc2",
|
||||
"name": "Shared Doc",
|
||||
"modifiedTime": "2026-03-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": False
|
||||
}],
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/shared", "/gdocs"),
|
||||
virtual="/gdocs/shared",
|
||||
directory="/gdocs/shared"), index)
|
||||
assert len(result) == 1
|
||||
assert "doc2" in result[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_file_path_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/file.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/file.gdoc.json",
|
||||
directory="/gdocs/owned/file.gdoc.json"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_invalid_path_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/bogus", "/gdocs"),
|
||||
virtual="/gdocs/bogus",
|
||||
directory="/gdocs/bogus"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_pushes_modified_range(accessor, index):
|
||||
captured = {}
|
||||
|
||||
async def fake_list(token_manager, mime_type=None, **kwargs):
|
||||
captured.update(kwargs)
|
||||
captured["mime_type"] = mime_type
|
||||
return []
|
||||
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files", new=fake_list):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/2026-05-*",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/2026-05-*",
|
||||
directory="/gdocs/owned",
|
||||
pattern="2026-05-*"), index)
|
||||
|
||||
assert captured["modified_after"] == "2026-05-01T00:00:00Z"
|
||||
assert captured["modified_before"] == "2026-06-01T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_filtered_does_not_cache(accessor, index):
|
||||
files = [{
|
||||
"id": "may",
|
||||
"name": "MayDoc",
|
||||
"modifiedTime": "2026-05-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
}]
|
||||
full_files = files + [{
|
||||
"id": "jan",
|
||||
"name": "JanDoc",
|
||||
"modifiedTime": "2026-01-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
}]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def fake_list(token_manager,
|
||||
mime_type=None,
|
||||
modified_after=None,
|
||||
modified_before=None,
|
||||
**kwargs):
|
||||
call_count["n"] += 1
|
||||
if modified_after:
|
||||
return files
|
||||
return full_files
|
||||
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files", new=fake_list):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/2026-05-*",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/2026-05-*",
|
||||
directory="/gdocs/owned",
|
||||
pattern="2026-05-*"), index)
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
|
||||
assert call_count["n"] == 2
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_filtered_bypasses_warm_cache(accessor, index):
|
||||
full_files = [
|
||||
{
|
||||
"id": "may",
|
||||
"name": "MayDoc",
|
||||
"modifiedTime": "2026-05-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "jan",
|
||||
"name": "JanDoc",
|
||||
"modifiedTime": "2026-01-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
},
|
||||
]
|
||||
may_only = [full_files[0]]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def fake_list(token_manager,
|
||||
mime_type=None,
|
||||
modified_after=None,
|
||||
modified_before=None,
|
||||
**kwargs):
|
||||
call_count["n"] += 1
|
||||
if modified_after:
|
||||
return may_only
|
||||
return full_files
|
||||
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files", new=fake_list):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/2026-05-*",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/2026-05-*",
|
||||
directory="/gdocs/owned",
|
||||
pattern="2026-05-*"), index)
|
||||
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_no_pattern_omits_range(accessor, index):
|
||||
captured = {}
|
||||
|
||||
async def fake_list(token_manager, mime_type=None, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return []
|
||||
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files", new=fake_list):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
|
||||
assert captured.get("modified_after") is None
|
||||
assert captured.get("modified_before") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_non_date_pattern_omits_range(accessor, index):
|
||||
captured = {}
|
||||
|
||||
async def fake_list(token_manager, mime_type=None, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return []
|
||||
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files", new=fake_list):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/*foo*", "/gdocs"),
|
||||
virtual="/gdocs/owned/*foo*",
|
||||
directory="/gdocs/owned",
|
||||
pattern="*foo*"), index)
|
||||
|
||||
assert captured.get("modified_after") is None
|
||||
assert captured.get("modified_before") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_filtered_then_stat_succeeds(accessor, index):
|
||||
files = [{
|
||||
"id": "may1",
|
||||
"name": "MayDoc",
|
||||
"modifiedTime": "2026-05-15T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": False
|
||||
}]
|
||||
}]
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
listed = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/shared/2026-05-*",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/shared/2026-05-*",
|
||||
directory="/gdocs/shared",
|
||||
pattern="2026-05-*"), index)
|
||||
assert len(listed) == 1
|
||||
matched = listed[0]
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key(matched, "/gdocs"),
|
||||
virtual=matched,
|
||||
directory=matched),
|
||||
index,
|
||||
)
|
||||
assert result.extra["doc_id"] == "may1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_owned_newest_first_across_cache(accessor, index):
|
||||
"""Relies on API mock newest-first, mirroring orderBy modifiedTime desc."""
|
||||
files = [
|
||||
{
|
||||
"id": "new",
|
||||
"name": "Latest",
|
||||
"modifiedTime": "2026-05-03T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "mid",
|
||||
"name": "Middle",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "old",
|
||||
"name": "Oldest",
|
||||
"modifiedTime": "2026-01-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}]
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
) as mock_list:
|
||||
first = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
second = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
assert mock_list.call_count == 1
|
||||
assert first == second
|
||||
assert "new" in first[0]
|
||||
assert "mid" in first[1]
|
||||
assert "old" in first[-1]
|
||||
@@ -0,0 +1,156 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdocs import GDocsAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdocs.stat import stat
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return GDocsAccessor(config=None, token_manager=None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs", "/gdocs"),
|
||||
virtual="/gdocs",
|
||||
directory="/gdocs"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_owned_dir(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "owned"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_shared_dir(accessor, index):
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/shared", "/gdocs"),
|
||||
virtual="/gdocs/shared",
|
||||
directory="/gdocs/shared"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "shared"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_doc(accessor, index):
|
||||
await index.set_dir("/gdocs/owned", [
|
||||
("2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
IndexEntry(id="doc1",
|
||||
name="My Doc",
|
||||
resource_type="gdocs/file",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
size=1000)),
|
||||
])
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json", "/gdocs"),
|
||||
virtual="/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json",
|
||||
directory="/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json"),
|
||||
index,
|
||||
)
|
||||
assert result.name == "2026-04-01_My_Doc__doc1.gdoc.json"
|
||||
assert result.type == FileType.JSON
|
||||
assert result.modified == "2026-04-01T00:00:00.000Z"
|
||||
assert result.extra["doc_id"] == "doc1"
|
||||
assert result.extra["doc_name"] == "My Doc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_not_found(accessor, index):
|
||||
files = [{
|
||||
"id": "doc1",
|
||||
"name": "My Doc",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}],
|
||||
}]
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/gdocs/owned/nonexistent.gdoc.json", "/gdocs"),
|
||||
virtual="/gdocs/owned/nonexistent.gdoc.json",
|
||||
directory="/gdocs/owned/nonexistent.gdoc.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_cache_miss_falls_back_via_readdir(accessor, index):
|
||||
files = [{
|
||||
"id": "doc1",
|
||||
"name": "My Doc",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"size": "1234",
|
||||
"owners": [{
|
||||
"me": True
|
||||
}],
|
||||
}]
|
||||
target = "/gdocs/owned/2026-04-01_My_Doc__doc1.gdoc.json"
|
||||
with patch(
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
) as mock_list:
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key(target, "/gdocs"),
|
||||
virtual=target,
|
||||
directory=target), index)
|
||||
assert result.type == FileType.JSON
|
||||
assert result.extra["doc_id"] == "doc1"
|
||||
assert mock_list.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_cache_miss_with_index_none_raises(accessor):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/doc.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/doc.gdoc.json",
|
||||
directory="/gdocs/owned/doc.gdoc.json"), None)
|
||||
@@ -0,0 +1,82 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdocs import GDocsAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdocs.unlink import unlink
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return GDocsAccessor(config=None, token_manager=None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_calls_delete_with_doc_id(accessor, index):
|
||||
await index.set_dir("/gdocs/owned", [
|
||||
("foo.gdoc.json",
|
||||
IndexEntry(id="doc1",
|
||||
name="Foo",
|
||||
resource_type="gdocs/file",
|
||||
vfs_name="foo.gdoc.json")),
|
||||
])
|
||||
with patch("mirage.core.google.tree_ops.delete_file",
|
||||
new_callable=AsyncMock) as mock_delete:
|
||||
await unlink(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/foo.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/foo.gdoc.json",
|
||||
directory="/gdocs/owned/foo.gdoc.json"), index)
|
||||
mock_delete.assert_awaited_once()
|
||||
assert mock_delete.await_args.args[1] == "doc1"
|
||||
listing = await index.list_dir("/gdocs/owned")
|
||||
assert listing.entries is None or listing.entries == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_virtual_dir_raises(accessor, index):
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await unlink(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned", "/gdocs"),
|
||||
virtual="/gdocs/owned",
|
||||
directory="/gdocs/owned"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlink_missing_raises(accessor, index):
|
||||
files = []
|
||||
with patch("mirage.core.gdocs.readdir.list_all_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await unlink(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/gdocs/owned/nope.gdoc.json",
|
||||
"/gdocs"),
|
||||
virtual="/gdocs/owned/nope.gdoc.json",
|
||||
directory="/gdocs/owned/nope.gdoc.json"), index)
|
||||
@@ -0,0 +1,70 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.gdocs._client import TokenManager
|
||||
from mirage.core.gdocs.update import batch_update
|
||||
from mirage.resource.gdocs.config import GDocsConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager():
|
||||
config = GDocsConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update(token_manager):
|
||||
payload = json.dumps({
|
||||
"requests": [{
|
||||
"insertText": {
|
||||
"location": {
|
||||
"index": 1
|
||||
},
|
||||
"text": "Hello"
|
||||
}
|
||||
}]
|
||||
})
|
||||
api_response = {"documentId": "abc123", "replies": [{}]}
|
||||
with patch(
|
||||
"mirage.core.gdocs.update.google_post",
|
||||
new_callable=AsyncMock,
|
||||
return_value=api_response,
|
||||
) as mock_post:
|
||||
result = await batch_update(token_manager, "abc123", payload)
|
||||
assert result["documentId"] == "abc123"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_invalid_json(token_manager):
|
||||
with pytest.raises(ValueError, match="requests"):
|
||||
await batch_update(token_manager, "abc123", "not json")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_missing_requests_key(token_manager):
|
||||
with pytest.raises(ValueError, match="requests"):
|
||||
await batch_update(token_manager, "abc123", '{"foo": "bar"}')
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,200 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdrive import GDriveAccessor
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdrive.read import read, read_bytes
|
||||
from mirage.core.google._client import TokenManager
|
||||
from mirage.core.google.config import GoogleConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GoogleConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(config):
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config, token_manager):
|
||||
return GDriveAccessor(config=config, token_manager=token_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
store = RAMIndexCacheStore()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes(token_manager):
|
||||
content = b"pdf content here"
|
||||
with patch(
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
new_callable=AsyncMock,
|
||||
return_value=content,
|
||||
) as mock_download:
|
||||
result = await read_bytes(token_manager, "file123")
|
||||
assert result == content
|
||||
mock_download.assert_called_once_with(token_manager, "file123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file(accessor, index):
|
||||
await index.put(
|
||||
"/Team Drive/report.pdf",
|
||||
IndexEntry(
|
||||
id="file123",
|
||||
name="report",
|
||||
resource_type="gdrive/file",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="report.pdf",
|
||||
extra={"drive_id": "drive1"},
|
||||
))
|
||||
content = b"pdf content here"
|
||||
with patch(
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
new_callable=AsyncMock,
|
||||
return_value=content,
|
||||
):
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="Team Drive/report.pdf",
|
||||
virtual="/Team Drive/report.pdf",
|
||||
directory="/Team Drive/report.pdf"), index)
|
||||
assert result == content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_shared_drive_raises_is_a_directory(accessor, index):
|
||||
await index.put(
|
||||
"/Team Drive",
|
||||
IndexEntry(
|
||||
id="drive1",
|
||||
name="Team Drive",
|
||||
resource_type="gdrive/shared_drive",
|
||||
vfs_name="Team Drive",
|
||||
extra={"drive_id": "drive1"},
|
||||
))
|
||||
with patch(
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="Team Drive",
|
||||
virtual="/Team Drive",
|
||||
directory="/Team Drive"),
|
||||
index,
|
||||
)
|
||||
mock_download.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_not_found(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="missing/file.txt",
|
||||
virtual="/missing/file.txt",
|
||||
directory="/missing/file.txt"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_auto_bootstraps_from_empty_index(accessor, index):
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
if folder_id == "root":
|
||||
return [{
|
||||
"id": "f1",
|
||||
"name": "report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
raise AssertionError(f"unexpected folder_id={folder_id}")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new=fake_list_files,
|
||||
),
|
||||
patch(
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b"pdf-bytes",
|
||||
),
|
||||
):
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="report.pdf",
|
||||
virtual="/report.pdf",
|
||||
directory="/report.pdf"),
|
||||
index,
|
||||
)
|
||||
assert result == b"pdf-bytes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_missing_file_raises_after_recursion(accessor, index):
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
if folder_id == "root":
|
||||
return [{
|
||||
"id": "f1",
|
||||
"name": "other.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
raise AssertionError(f"unexpected folder_id={folder_id}")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new=fake_list_files,
|
||||
),
|
||||
patch(
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=AssertionError("should not call download_file"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="missing.txt",
|
||||
virtual="/missing.txt",
|
||||
directory="/missing.txt"),
|
||||
index,
|
||||
)
|
||||
@@ -0,0 +1,332 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdrive import GDriveAccessor
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdrive.readdir import readdir
|
||||
from mirage.core.google._client import TokenManager
|
||||
from mirage.core.google.config import GoogleConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GoogleConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(config):
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config, token_manager):
|
||||
return GDriveAccessor(config=config, token_manager=token_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
files = [
|
||||
{
|
||||
"id": "f1",
|
||||
"name": "readme.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True,
|
||||
"emailAddress": "me@gmail.com"
|
||||
}],
|
||||
"capabilities": {
|
||||
"canEdit": True
|
||||
},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert "/readme.txt" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_cached(accessor, index):
|
||||
entry = IndexEntry(
|
||||
id="f1",
|
||||
name="cached.txt",
|
||||
resource_type="gdrive/file",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="cached.txt",
|
||||
)
|
||||
await index.set_dir("/", [("cached.txt", entry)])
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert any("cached.txt" in r for r in result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_subfolder(accessor, index):
|
||||
await index.put(
|
||||
"/docs",
|
||||
IndexEntry(
|
||||
id="folder1",
|
||||
name="docs",
|
||||
resource_type="gdrive/folder",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="docs",
|
||||
))
|
||||
|
||||
files = [
|
||||
{
|
||||
"id": "f2",
|
||||
"name": "notes.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {
|
||||
"canEdit": False
|
||||
},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
) as mock_list:
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="docs", virtual="/docs", directory="/docs"),
|
||||
index)
|
||||
assert "/docs/notes.txt" in result
|
||||
mock_list.assert_called_once_with(accessor.token_manager,
|
||||
folder_id="folder1",
|
||||
drive_id=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_repopulates_evicted_subfolder(accessor, index):
|
||||
root_files = [{
|
||||
"id": "folder1",
|
||||
"name": "docs",
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
docs_files = [{
|
||||
"id": "f2",
|
||||
"name": "notes.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
if folder_id == "root":
|
||||
return root_files
|
||||
if folder_id == "folder1":
|
||||
return docs_files
|
||||
raise AssertionError(f"unexpected folder_id={folder_id}")
|
||||
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new=fake_list_files,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="docs", virtual="/docs", directory="/docs"),
|
||||
index)
|
||||
assert "/docs/notes.txt" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_missing_subfolder_raises_after_recursion(
|
||||
accessor, index):
|
||||
root_files = [{
|
||||
"id": "f1",
|
||||
"name": "other.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
if folder_id == "root":
|
||||
return root_files
|
||||
raise AssertionError(f"should not list folder_id={folder_id}")
|
||||
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new=fake_list_files,
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="docs",
|
||||
virtual="/docs",
|
||||
directory="/docs"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_includes_shared_drives(accessor, index):
|
||||
files = [{
|
||||
"id": "f1",
|
||||
"name": "readme.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
drives = [{"id": "drive1", "name": "Team Drive"}]
|
||||
with patch("mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock, return_value=files), \
|
||||
patch("mirage.core.gdrive.readdir.list_shared_drives",
|
||||
new_callable=AsyncMock, return_value=drives):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert "/readme.txt" in result
|
||||
# Shared Drives appear as top-level directories.
|
||||
assert "/Team Drive/" in result
|
||||
# The drive id is carried on the cached entry for nested listings.
|
||||
entry = (await index.get("/Team Drive")).entry
|
||||
assert entry is not None
|
||||
assert entry.extra.get("drive_id") == "drive1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_uniquifies_duplicate_shared_drive_names(
|
||||
accessor, index):
|
||||
drives = [
|
||||
{
|
||||
"id": "drive1",
|
||||
"name": "Team"
|
||||
},
|
||||
{
|
||||
"id": "drive2",
|
||||
"name": "Team"
|
||||
},
|
||||
{
|
||||
"id": "drive3",
|
||||
"name": "Team"
|
||||
},
|
||||
]
|
||||
with patch("mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock, return_value=[]), \
|
||||
patch("mirage.core.gdrive.readdir.list_shared_drives",
|
||||
new_callable=AsyncMock, return_value=drives):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
|
||||
assert result == [
|
||||
"/Team/",
|
||||
"/Team [Shared Drive]/",
|
||||
"/Team [Shared Drive 2]/",
|
||||
]
|
||||
assert (await index.get("/Team")).entry.id == "drive1"
|
||||
assert (await index.get("/Team [Shared Drive]")).entry.id == "drive2"
|
||||
assert (await index.get("/Team [Shared Drive 2]")).entry.id == "drive3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_shared_drives_best_effort(accessor, index):
|
||||
"""If Shared Drive enumeration fails, My Drive listing still succeeds."""
|
||||
files = [{
|
||||
"id": "f1",
|
||||
"name": "readme.txt",
|
||||
"mimeType": "text/plain",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
with patch("mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock, return_value=files), \
|
||||
patch("mirage.core.gdrive.readdir.list_shared_drives",
|
||||
new_callable=AsyncMock, side_effect=RuntimeError("no scope")):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert "/readme.txt" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_workspace_files_get_extensions(accessor, index):
|
||||
files = [
|
||||
{
|
||||
"id": "d1",
|
||||
"name": "My Document",
|
||||
"mimeType": "application/vnd.google-apps.document",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [{
|
||||
"me": True,
|
||||
"emailAddress": "me@gmail.com"
|
||||
}],
|
||||
"capabilities": {
|
||||
"canEdit": True
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "My Sheet",
|
||||
"mimeType": "application/vnd.google-apps.spreadsheet",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {
|
||||
"canEdit": False
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"name": "My Slides",
|
||||
"mimeType": "application/vnd.google-apps.presentation",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
):
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert "/My Document.gdoc.json" in result
|
||||
assert "/My Sheet.gsheet.json" in result
|
||||
assert "/My Slides.gslide.json" in result
|
||||
@@ -0,0 +1,183 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdrive import GDriveAccessor
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdrive.stat import stat
|
||||
from mirage.core.google._client import TokenManager
|
||||
from mirage.core.google.config import GoogleConfig
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GoogleConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(config):
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config, token_manager):
|
||||
return GDriveAccessor(config=config, token_manager=token_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
store = RAMIndexCacheStore()
|
||||
return store
|
||||
|
||||
|
||||
async def _populate_index(index):
|
||||
await index.put(
|
||||
"/report.pdf",
|
||||
IndexEntry(
|
||||
id="f1",
|
||||
name="report",
|
||||
resource_type="gdrive/file",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="report.pdf",
|
||||
size=1024,
|
||||
))
|
||||
await index.put(
|
||||
"/docs",
|
||||
IndexEntry(
|
||||
id="folder1",
|
||||
name="docs",
|
||||
resource_type="gdrive/folder",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="docs",
|
||||
))
|
||||
return index
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(accessor, index):
|
||||
idx = await _populate_index(index)
|
||||
result = await stat(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
idx)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file(accessor, index):
|
||||
idx = await _populate_index(index)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="report.pdf",
|
||||
virtual="/report.pdf",
|
||||
directory="/report.pdf"), idx)
|
||||
assert result.name == "report.pdf"
|
||||
assert result.size == 1024
|
||||
assert result.extra["file_id"] == "f1"
|
||||
assert result.extra["resource_type"] == "gdrive/file"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_folder(accessor, index):
|
||||
idx = await _populate_index(index)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="docs", virtual="/docs", directory="/docs"),
|
||||
idx)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.extra["file_id"] == "folder1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_shared_drive_is_directory(accessor, index):
|
||||
await index.put(
|
||||
"/Team Drive",
|
||||
IndexEntry(
|
||||
id="drive1",
|
||||
name="Team Drive",
|
||||
resource_type="gdrive/shared_drive",
|
||||
vfs_name="Team Drive",
|
||||
extra={"drive_id": "drive1"},
|
||||
))
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="Team Drive",
|
||||
virtual="/Team Drive",
|
||||
directory="/Team Drive"),
|
||||
index,
|
||||
)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.extra["file_id"] == "drive1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_not_found(accessor, index):
|
||||
idx = await _populate_index(index)
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="nonexistent.txt",
|
||||
virtual="/nonexistent.txt",
|
||||
directory="/nonexistent.txt"), idx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_cache_miss_falls_back_via_readdir(accessor, index):
|
||||
files = [{
|
||||
"id": "f99",
|
||||
"name": "fresh.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"modifiedTime": "2026-04-15T00:00:00.000Z",
|
||||
"size": "2048",
|
||||
}]
|
||||
with patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new_callable=AsyncMock,
|
||||
return_value=files,
|
||||
) as mock_list:
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="fresh.pdf",
|
||||
virtual="/fresh.pdf",
|
||||
directory="/fresh.pdf"), index)
|
||||
assert result.name == "fresh.pdf"
|
||||
assert result.extra["file_id"] == "f99"
|
||||
assert mock_list.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_index_none_raises(accessor):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="x.pdf",
|
||||
virtual="/x.pdf",
|
||||
directory="/x.pdf"), None)
|
||||
@@ -0,0 +1,188 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.gdrive import GDriveAccessor
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.gdrive.stream import stream
|
||||
from mirage.core.google._client import TokenManager
|
||||
from mirage.core.google.config import GoogleConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GoogleConfig(
|
||||
client_id="test-id",
|
||||
client_secret="test-secret",
|
||||
refresh_token="test-refresh",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager(config):
|
||||
mgr = TokenManager(config)
|
||||
mgr._access_token = "fake-token"
|
||||
mgr._expires_at = 9999999999
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config, token_manager):
|
||||
return GDriveAccessor(config=config, token_manager=token_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
async def _fake_download_stream(*args, **kwargs):
|
||||
yield b"chunk1"
|
||||
yield b"chunk2"
|
||||
|
||||
|
||||
async def _collect(source):
|
||||
data = b""
|
||||
async for chunk in source:
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_file(accessor, index):
|
||||
await index.put(
|
||||
"/Team Drive/report.pdf",
|
||||
IndexEntry(
|
||||
id="file123",
|
||||
name="report",
|
||||
resource_type="gdrive/file",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="report.pdf",
|
||||
extra={"drive_id": "drive1"},
|
||||
))
|
||||
with patch(
|
||||
"mirage.core.gdrive.stream.download_file_stream",
|
||||
side_effect=_fake_download_stream,
|
||||
):
|
||||
data = await _collect(
|
||||
stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="Team Drive/report.pdf",
|
||||
virtual="/Team Drive/report.pdf",
|
||||
directory="/Team Drive/report.pdf"), index))
|
||||
assert data == b"chunk1chunk2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_not_found(accessor, index):
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
return []
|
||||
|
||||
with patch("mirage.core.gdrive.readdir.list_files", new=fake_list_files):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await _collect(
|
||||
stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="missing/file.txt",
|
||||
virtual="/missing/file.txt",
|
||||
directory="/missing/file.txt"), index))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_auto_bootstraps_from_empty_index(accessor, index):
|
||||
|
||||
async def fake_list_files(_tm, folder_id, drive_id=None):
|
||||
if folder_id == "root":
|
||||
return [{
|
||||
"id": "f1",
|
||||
"name": "report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"modifiedTime": "2026-04-01T00:00:00.000Z",
|
||||
"owners": [],
|
||||
"capabilities": {},
|
||||
}]
|
||||
raise AssertionError(f"unexpected folder_id={folder_id}")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
new=fake_list_files,
|
||||
),
|
||||
patch(
|
||||
"mirage.core.gdrive.stream.download_file_stream",
|
||||
side_effect=_fake_download_stream,
|
||||
),
|
||||
):
|
||||
data = await _collect(
|
||||
stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="report.pdf",
|
||||
virtual="/report.pdf",
|
||||
directory="/report.pdf"),
|
||||
index,
|
||||
))
|
||||
assert data == b"chunk1chunk2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_folder_raises(accessor, index):
|
||||
await index.put(
|
||||
"/data",
|
||||
IndexEntry(
|
||||
id="folder1",
|
||||
name="data",
|
||||
resource_type="gdrive/folder",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="data",
|
||||
))
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await _collect(
|
||||
stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="data",
|
||||
virtual="/data",
|
||||
directory="/data"), index))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_shared_drive_raises(accessor, index):
|
||||
await index.put(
|
||||
"/Team Drive",
|
||||
IndexEntry(
|
||||
id="drive1",
|
||||
name="Team Drive",
|
||||
resource_type="gdrive/shared_drive",
|
||||
vfs_name="Team Drive",
|
||||
extra={"drive_id": "drive1"},
|
||||
))
|
||||
with patch(
|
||||
"mirage.core.gdrive.stream.download_file_stream",
|
||||
side_effect=_fake_download_stream,
|
||||
) as mock_download:
|
||||
with pytest.raises(IsADirectoryError):
|
||||
await _collect(
|
||||
stream(
|
||||
accessor,
|
||||
PathSpec(resource_path="Team Drive",
|
||||
virtual="/Team Drive",
|
||||
directory="/Team Drive"),
|
||||
index,
|
||||
))
|
||||
mock_download.assert_not_called()
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,35 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.github._client import github_headers, github_url
|
||||
|
||||
|
||||
def test_github_headers_contains_auth():
|
||||
headers = github_headers("ghp_test123")
|
||||
assert headers["Authorization"] == "Bearer ghp_test123"
|
||||
assert headers["Accept"] == "application/vnd.github+json"
|
||||
assert "X-GitHub-Api-Version" in headers
|
||||
|
||||
|
||||
def test_github_url_simple():
|
||||
url = github_url("/repos/{owner}/{repo}/git/trees/{sha}",
|
||||
owner="acme",
|
||||
repo="proj",
|
||||
sha="abc123")
|
||||
assert url == "https://api.github.com/repos/acme/proj/git/trees/abc123"
|
||||
|
||||
|
||||
def test_github_url_no_params():
|
||||
url = github_url("/rate_limit")
|
||||
assert url == "https://api.github.com/rate_limit"
|
||||
@@ -0,0 +1,126 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.find import find
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _index() -> RAMIndexCacheStore:
|
||||
index = RAMIndexCacheStore()
|
||||
index._entries.update({
|
||||
"/src":
|
||||
IndexEntry(id="a", name="src", resource_type="folder", size=None),
|
||||
"/src/main.py":
|
||||
IndexEntry(id="b", name="main.py", resource_type="file", size=120),
|
||||
"/src/utils":
|
||||
IndexEntry(id="c", name="utils", resource_type="folder", size=None),
|
||||
"/src/utils/helpers.py":
|
||||
IndexEntry(id="d", name="helpers.py", resource_type="file", size=80),
|
||||
"/README.md":
|
||||
IndexEntry(id="e", name="README.md", resource_type="file", size=50),
|
||||
})
|
||||
return index
|
||||
|
||||
|
||||
def _spec(path: str, prefix: str = "") -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key(path, prefix),
|
||||
virtual=path,
|
||||
directory=path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_all_from_root():
|
||||
results = await find(None, _spec("/"), index=_index())
|
||||
assert results == [
|
||||
"/", "/README.md", "/src", "/src/main.py", "/src/utils",
|
||||
"/src/utils/helpers.py"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_name_pattern():
|
||||
results = await find(None, _spec("/"), name="*.py", index=_index())
|
||||
assert results == ["/src/main.py", "/src/utils/helpers.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_directory():
|
||||
results = await find(None, _spec("/src"), type="d", index=_index())
|
||||
assert results == ["/src", "/src/utils"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_file_under_subdir():
|
||||
results = await find(None, _spec("/src"), type="f", index=_index())
|
||||
assert results == ["/src/main.py", "/src/utils/helpers.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_maxdepth():
|
||||
results = await find(None, _spec("/src"), maxdepth=1, index=_index())
|
||||
assert results == ["/src", "/src/main.py", "/src/utils"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_mindepth():
|
||||
results = await find(None, _spec("/src"), mindepth=2, index=_index())
|
||||
assert results == ["/src/utils/helpers.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_strips_mount_prefix():
|
||||
results = await find(None,
|
||||
_spec("/github/src", prefix="/github"),
|
||||
type="f",
|
||||
index=_index())
|
||||
assert results == ["/src/main.py", "/src/utils/helpers.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_size_filters():
|
||||
# Directories contribute size 0 to -size, so the root is excluded
|
||||
# under a positive minimum (#318).
|
||||
results = await find(None, _spec("/"), min_size=100, index=_index())
|
||||
assert results == ["/src/main.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_file_start_path():
|
||||
results = await find(None, _spec("/src/main.py"), index=_index())
|
||||
assert results == ["/src/main.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_size_filters_file_start():
|
||||
too_big = await find(None,
|
||||
_spec("/src/main.py"),
|
||||
max_size=50,
|
||||
index=_index())
|
||||
assert too_big == []
|
||||
big_enough = await find(None,
|
||||
_spec("/src/main.py"),
|
||||
min_size=100,
|
||||
index=_index())
|
||||
assert big_enough == ["/src/main.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_no_index_raises():
|
||||
with pytest.raises(ValueError):
|
||||
await find(None, _spec("/"), index=None)
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.read import read_bytes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GitHubConfig(token="ghp_test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.read.github_get", new_callable=AsyncMock)
|
||||
async def test_read_bytes_utf8(mock_get, config):
|
||||
content = b"hello world"
|
||||
mock_get.return_value = {"content": base64.b64encode(content).decode()}
|
||||
result = await read_bytes(config, "acme", "proj", "sha123")
|
||||
assert result == content
|
||||
assert result.decode("utf-8") == "hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.read.github_get", new_callable=AsyncMock)
|
||||
async def test_read_bytes_binary(mock_get, config):
|
||||
content = bytes(range(256))
|
||||
mock_get.return_value = {"content": base64.b64encode(content).decode()}
|
||||
result = await read_bytes(config, "acme", "proj", "sha456")
|
||||
assert result == content
|
||||
@@ -0,0 +1,115 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.readdir import readdir
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
def _index_from_tree(tree: dict[str, TreeEntry]) -> RAMIndexCacheStore:
|
||||
index = RAMIndexCacheStore()
|
||||
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
|
||||
for path, entry in tree.items():
|
||||
parts = path.rsplit("/", 1)
|
||||
if len(parts) == 2:
|
||||
parent, name = "/" + parts[0], parts[1]
|
||||
else:
|
||||
parent, name = "/", parts[0]
|
||||
resource_type = "folder" if entry.type == "tree" else "file"
|
||||
idx_entry = IndexEntry(
|
||||
id=entry.sha,
|
||||
name=name,
|
||||
resource_type=resource_type,
|
||||
size=entry.size,
|
||||
)
|
||||
dirs[parent].append((name, idx_entry))
|
||||
for parent, entries in dirs.items():
|
||||
index._entries.update({
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/"):
|
||||
e
|
||||
for name, e in entries
|
||||
})
|
||||
child_keys = sorted(
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/")
|
||||
for name, _ in entries)
|
||||
index._children[parent] = child_keys
|
||||
index._expiry[parent] = datetime.now(
|
||||
timezone.utc) + timedelta(days=365)
|
||||
return index
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree():
|
||||
return {
|
||||
"src":
|
||||
TreeEntry(path="src", type="tree", sha="aaa", size=None),
|
||||
"src/main.py":
|
||||
TreeEntry(path="src/main.py", type="blob", sha="bbb", size=120),
|
||||
"src/utils":
|
||||
TreeEntry(path="src/utils", type="tree", sha="ccc", size=None),
|
||||
"src/utils/helpers.py":
|
||||
TreeEntry(path="src/utils/helpers.py", type="blob", sha="ddd",
|
||||
size=80),
|
||||
"README.md":
|
||||
TreeEntry(path="README.md", type="blob", sha="eee", size=50),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await readdir(
|
||||
None, PathSpec(resource_path="", virtual="/", directory="/"), index)
|
||||
assert result == ["/README.md", "/src"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_subdirectory(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await readdir(
|
||||
None, PathSpec(resource_path="src", virtual="/src", directory="/src"),
|
||||
index)
|
||||
assert result == ["/src/main.py", "/src/utils"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_nested(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await readdir(
|
||||
None,
|
||||
PathSpec(resource_path="src/utils",
|
||||
virtual="/src/utils",
|
||||
directory="/src/utils"), index)
|
||||
assert result == ["/src/utils/helpers.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_missing_directory(tree):
|
||||
index = _index_from_tree(tree)
|
||||
accessor = MagicMock()
|
||||
accessor.truncated = False
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="nonexistent",
|
||||
virtual="/nonexistent",
|
||||
directory="/nonexistent"), index)
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.repo import fetch_default_branch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GitHubConfig(token="ghp_test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.repo.github_get", new_callable=AsyncMock)
|
||||
async def test_fetch_default_branch_main(mock_get, config):
|
||||
mock_get.return_value = {"default_branch": "main"}
|
||||
result = await fetch_default_branch(config, "acme", "proj")
|
||||
assert result == "main"
|
||||
mock_get.assert_awaited_once_with(config.token,
|
||||
"/repos/{owner}/{repo}",
|
||||
owner="acme",
|
||||
repo="proj")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.repo.github_get", new_callable=AsyncMock)
|
||||
async def test_fetch_default_branch_master(mock_get, config):
|
||||
mock_get.return_value = {"default_branch": "master"}
|
||||
result = await fetch_default_branch(config, "acme", "proj")
|
||||
assert result == "master"
|
||||
@@ -0,0 +1,77 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.scope import (count_scope_files, is_repo_root,
|
||||
scope_relative_key)
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entries():
|
||||
|
||||
def _f():
|
||||
return SimpleNamespace(resource_type="file")
|
||||
|
||||
def _d():
|
||||
return SimpleNamespace(resource_type="folder")
|
||||
|
||||
return {
|
||||
"/README.md": _f(),
|
||||
"/src": _d(),
|
||||
"/src/main.py": _f(),
|
||||
"/src/utils.py": _f(),
|
||||
"/src/models": _d(),
|
||||
"/src/models/user.py": _f(),
|
||||
}
|
||||
|
||||
|
||||
def test_scope_relative_key_strips_mount_prefix():
|
||||
path = PathSpec(resource_path=mount_key("/gh/src", "/gh"),
|
||||
virtual="/gh/src",
|
||||
directory="/gh/src")
|
||||
assert scope_relative_key(path) == "/src"
|
||||
|
||||
|
||||
def test_scope_relative_key_root_becomes_slash():
|
||||
path = PathSpec(resource_path=mount_key("/gh", "/gh"),
|
||||
virtual="/gh",
|
||||
directory="/gh")
|
||||
assert scope_relative_key(path) == "/"
|
||||
|
||||
|
||||
def test_is_repo_root():
|
||||
assert is_repo_root("/")
|
||||
assert is_repo_root("")
|
||||
assert not is_repo_root("/src")
|
||||
|
||||
|
||||
def test_count_scope_files_root_counts_all(entries):
|
||||
assert count_scope_files(entries, "/") == 4
|
||||
|
||||
|
||||
def test_count_scope_files_subdir(entries):
|
||||
assert count_scope_files(entries, "/src") == 3
|
||||
|
||||
|
||||
def test_count_scope_files_single_file(entries):
|
||||
assert count_scope_files(entries, "/src/main.py") == 1
|
||||
|
||||
|
||||
def test_count_scope_files_missing(entries):
|
||||
assert count_scope_files(entries, "/nope") == 0
|
||||
@@ -0,0 +1,117 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.search import SearchResult, narrow_paths, search_code
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GitHubConfig(token="ghp_test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.github_get", new_callable=AsyncMock)
|
||||
async def test_search_code_basic(mock_get, config):
|
||||
mock_get.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"path": "src/main.py",
|
||||
"sha": "aaa"
|
||||
},
|
||||
{
|
||||
"path": "src/utils.py",
|
||||
"sha": "bbb"
|
||||
},
|
||||
]
|
||||
}
|
||||
result = await search_code(config, "acme", "proj", "import os")
|
||||
assert len(result) == 2
|
||||
assert result[0] == SearchResult(path="src/main.py", sha="aaa")
|
||||
mock_get.assert_awaited_once_with(config.token,
|
||||
"/search/code",
|
||||
params={"q": "import os repo:acme/proj"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.github_get", new_callable=AsyncMock)
|
||||
async def test_search_code_empty_results(mock_get, config):
|
||||
mock_get.return_value = {"items": []}
|
||||
result = await search_code(config, "acme", "proj", "nonexistent")
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.github_get", new_callable=AsyncMock)
|
||||
async def test_search_code_with_path_filter(mock_get, config):
|
||||
mock_get.return_value = {"items": [{"path": "src/main.py", "sha": "aaa"}]}
|
||||
await search_code(config, "acme", "proj", "import os", path_filter="src/")
|
||||
mock_get.assert_awaited_once_with(
|
||||
config.token,
|
||||
"/search/code",
|
||||
params={"q": "import os repo:acme/proj path:src/"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.search_code", new_callable=AsyncMock)
|
||||
async def test_narrow_paths_strips_leading_slash_in_filter(
|
||||
mock_search, config):
|
||||
mock_search.return_value = [SearchResult(path="src/main.py", sha="aaa")]
|
||||
paths = [PathSpec(resource_path="src", virtual="/src", directory="/src")]
|
||||
await narrow_paths(config, "acme", "proj", "import", paths)
|
||||
_, kwargs = mock_search.await_args
|
||||
assert kwargs["path_filter"] == "src"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.search_code", new_callable=AsyncMock)
|
||||
async def test_narrow_paths_root_uses_no_filter(mock_search, config):
|
||||
mock_search.return_value = [SearchResult(path="src/main.py", sha="aaa")]
|
||||
paths = [PathSpec(resource_path="", virtual="/", directory="/")]
|
||||
await narrow_paths(config, "acme", "proj", "import", paths)
|
||||
_, kwargs = mock_search.await_args
|
||||
assert kwargs["path_filter"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.search_code", new_callable=AsyncMock)
|
||||
async def test_narrow_paths_normalizes_results_with_leading_slash(
|
||||
mock_search, config):
|
||||
mock_search.return_value = [
|
||||
SearchResult(path="src/main.py", sha="aaa"),
|
||||
SearchResult(path="src/utils.py", sha="bbb"),
|
||||
]
|
||||
paths = [
|
||||
PathSpec(resource_path=mount_key("/gh", "/gh"),
|
||||
virtual="/gh",
|
||||
directory="/gh")
|
||||
]
|
||||
out = await narrow_paths(config, "acme", "proj", "import", paths)
|
||||
assert [p.virtual for p in out] == ["/gh/src/main.py", "/gh/src/utils.py"]
|
||||
assert [p.resource_path for p in out] == ["src/main.py", "src/utils.py"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.search.search_code", new_callable=AsyncMock)
|
||||
async def test_narrow_paths_logs_and_continues_on_error(mock_search, config):
|
||||
mock_search.side_effect = RuntimeError("boom")
|
||||
paths = [PathSpec(resource_path="src", virtual="/src", directory="/src")]
|
||||
out = await narrow_paths(config, "acme", "proj", "import", paths)
|
||||
assert out == []
|
||||
@@ -0,0 +1,125 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.stat import stat
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
def _index_from_tree(tree: dict[str, TreeEntry]) -> RAMIndexCacheStore:
|
||||
index = RAMIndexCacheStore()
|
||||
dirs: dict[str, list[tuple[str, IndexEntry]]] = defaultdict(list)
|
||||
for path, entry in tree.items():
|
||||
parts = path.rsplit("/", 1)
|
||||
if len(parts) == 2:
|
||||
parent, name = "/" + parts[0], parts[1]
|
||||
else:
|
||||
parent, name = "/", parts[0]
|
||||
resource_type = "folder" if entry.type == "tree" else "file"
|
||||
idx_entry = IndexEntry(
|
||||
id=entry.sha,
|
||||
name=name,
|
||||
resource_type=resource_type,
|
||||
size=entry.size,
|
||||
)
|
||||
dirs[parent].append((name, idx_entry))
|
||||
for parent, entries in dirs.items():
|
||||
index._entries.update({
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/"):
|
||||
e
|
||||
for name, e in entries
|
||||
})
|
||||
child_keys = sorted(
|
||||
("/" + parent.strip("/") + "/" + name).replace("//", "/")
|
||||
for name, _ in entries)
|
||||
index._children[parent] = child_keys
|
||||
index._expiry[parent] = datetime.now(
|
||||
timezone.utc) + timedelta(days=365)
|
||||
return index
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree():
|
||||
return {
|
||||
"src":
|
||||
TreeEntry(path="src", type="tree", sha="aaa", size=None),
|
||||
"src/main.py":
|
||||
TreeEntry(path="src/main.py", type="blob", sha="bbb", size=120),
|
||||
"README.md":
|
||||
TreeEntry(path="README.md", type="blob", sha="ccc", size=50),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await stat(
|
||||
None,
|
||||
PathSpec(resource_path="src/main.py",
|
||||
virtual="/src/main.py",
|
||||
directory="/src/main.py"), index)
|
||||
assert result.name == "main.py"
|
||||
assert result.size == 120
|
||||
assert result.type == FileType.TEXT
|
||||
assert result.extra == {"sha": "bbb"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_directory(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await stat(
|
||||
None, PathSpec(resource_path="src", virtual="/src", directory="/src"),
|
||||
index)
|
||||
assert result.name == "src"
|
||||
assert result.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await stat(None,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result.name == "/"
|
||||
assert result.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_not_found(tree):
|
||||
index = _index_from_tree(tree)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
None,
|
||||
PathSpec(resource_path="nonexistent.py",
|
||||
virtual="/nonexistent.py",
|
||||
directory="/nonexistent.py"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_strip_slashes(tree):
|
||||
index = _index_from_tree(tree)
|
||||
result = await stat(
|
||||
None,
|
||||
PathSpec(resource_path="README.md",
|
||||
virtual="/README.md",
|
||||
directory="/README.md"), index)
|
||||
assert result.name == "README.md"
|
||||
assert result.size == 50
|
||||
@@ -0,0 +1,66 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.github.stat import stat
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_stat_returns_fingerprint_from_blob_sha():
|
||||
index = RAMIndexCacheStore()
|
||||
entry = IndexEntry(
|
||||
id="abc123",
|
||||
name="main.py",
|
||||
resource_type="file",
|
||||
size=42,
|
||||
)
|
||||
index._entries["/src/main.py"] = entry
|
||||
index._children["/src"] = ["/src/main.py"]
|
||||
index._expiry["/src"] = datetime.now(timezone.utc) + timedelta(days=365)
|
||||
|
||||
result = await stat(
|
||||
None,
|
||||
PathSpec(resource_path="src/main.py",
|
||||
virtual="/src/main.py",
|
||||
directory="/src/main.py"),
|
||||
index,
|
||||
)
|
||||
|
||||
assert result.fingerprint == "abc123"
|
||||
assert result.extra == {"sha": "abc123"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_stat_directory_has_no_fingerprint():
|
||||
index = RAMIndexCacheStore()
|
||||
entry = IndexEntry(
|
||||
id="dir_sha",
|
||||
name="src",
|
||||
resource_type="folder",
|
||||
)
|
||||
index._entries["/src"] = entry
|
||||
|
||||
result = await stat(
|
||||
None,
|
||||
PathSpec(resource_path="src", virtual="/src", directory="/src"),
|
||||
index,
|
||||
)
|
||||
|
||||
assert result.fingerprint is None
|
||||
@@ -0,0 +1,85 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.github.config import GitHubConfig
|
||||
from mirage.core.github.tree import fetch_tree
|
||||
from mirage.core.github.tree_entry import TreeEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return GitHubConfig(token="ghp_test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.tree.github_get", new_callable=AsyncMock)
|
||||
async def test_fetch_tree_parses_entries(mock_get, config):
|
||||
mock_get.return_value = {
|
||||
"truncated":
|
||||
False,
|
||||
"tree": [
|
||||
{
|
||||
"path": "src",
|
||||
"type": "tree",
|
||||
"sha": "aaa",
|
||||
"size": None
|
||||
},
|
||||
{
|
||||
"path": "src/main.py",
|
||||
"type": "blob",
|
||||
"sha": "bbb",
|
||||
"size": 120
|
||||
},
|
||||
],
|
||||
}
|
||||
tree, truncated = await fetch_tree(config, "acme", "proj", "main")
|
||||
assert "src" in tree
|
||||
assert "src/main.py" in tree
|
||||
assert tree["src"] == TreeEntry(path="src",
|
||||
type="tree",
|
||||
sha="aaa",
|
||||
size=None)
|
||||
assert tree["src/main.py"] == TreeEntry(path="src/main.py",
|
||||
type="blob",
|
||||
sha="bbb",
|
||||
size=120)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.tree.github_get", new_callable=AsyncMock)
|
||||
async def test_fetch_tree_truncation_warning(mock_get, config, caplog):
|
||||
mock_get.return_value = {"truncated": True, "tree": []}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await fetch_tree(config, "acme", "proj", "main")
|
||||
assert "truncated" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mirage.core.github.tree.github_get", new_callable=AsyncMock)
|
||||
async def test_fetch_tree_passes_params(mock_get, config):
|
||||
mock_get.return_value = {"tree": []}
|
||||
await fetch_tree(config, "acme", "proj", "v1")
|
||||
mock_get.assert_awaited_once_with(
|
||||
config.token,
|
||||
"/repos/{owner}/{repo}/git/trees/{ref}",
|
||||
owner="acme",
|
||||
repo="proj",
|
||||
ref="v1",
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user