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,120 @@
|
||||
# ========= 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.slack.channels import list_channels, list_dms
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels(config):
|
||||
mock_data = {
|
||||
"ok":
|
||||
True,
|
||||
"channels": [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "general"
|
||||
},
|
||||
{
|
||||
"id": "C002",
|
||||
"name": "random"
|
||||
},
|
||||
],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await list_channels(config)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "general"
|
||||
assert result[1]["id"] == "C002"
|
||||
mock_get.assert_called_once()
|
||||
call_kwargs = mock_get.call_args
|
||||
assert call_kwargs.kwargs["params"]["types"] == \
|
||||
"public_channel,private_channel"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels_pagination(config):
|
||||
page1 = {
|
||||
"ok": True,
|
||||
"channels": [{
|
||||
"id": "C001",
|
||||
"name": "general"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cursor_abc"
|
||||
},
|
||||
}
|
||||
page2 = {
|
||||
"ok": True,
|
||||
"channels": [{
|
||||
"id": "C002",
|
||||
"name": "random"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[page1, page2],
|
||||
) as mock_get:
|
||||
result = await list_channels(config)
|
||||
|
||||
assert len(result) == 2
|
||||
assert mock_get.call_count == 2
|
||||
second_call = mock_get.call_args_list[1]
|
||||
assert second_call.kwargs["params"]["cursor"] == "cursor_abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dms(config):
|
||||
mock_data = {
|
||||
"ok": True,
|
||||
"channels": [{
|
||||
"id": "D001",
|
||||
"user": "U001"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
) as mock_get:
|
||||
result = await list_dms(config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "D001"
|
||||
call_kwargs = mock_get.call_args
|
||||
assert call_kwargs.kwargs["params"]["types"] == "im,mpim"
|
||||
@@ -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 unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.slack.channels import list_channels_stream
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_channels_stream_yields_pages():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"channels": [{
|
||||
"id": "C1"
|
||||
}, {
|
||||
"id": "C2"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"channels": [{
|
||||
"id": "C3"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
}
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
async def fake_get(_cfg, method, params=None, token=None):
|
||||
assert method == "conversations.list"
|
||||
calls.append(dict(params or {}))
|
||||
return pages[len(calls) - 1]
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
seen = []
|
||||
async for page in list_channels_stream(cfg):
|
||||
seen.append(page)
|
||||
assert [ch["id"] for ch in seen[0]] == ["C1", "C2"]
|
||||
assert [ch["id"] for ch in seen[1]] == ["C3"]
|
||||
assert calls[0]["types"] == "public_channel,private_channel"
|
||||
assert calls[0]["exclude_archived"] == "true"
|
||||
assert calls[0]["limit"] == 200
|
||||
@@ -0,0 +1,191 @@
|
||||
# ========= 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.slack._client import slack_get, slack_post
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_get_success(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"ok": True,
|
||||
"channels": [],
|
||||
})
|
||||
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.slack._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 slack_get(config,
|
||||
"conversations.list",
|
||||
params={"limit": 10})
|
||||
|
||||
assert result["ok"] is True
|
||||
mock_session.get.assert_called_once()
|
||||
call_kwargs = mock_session.get.call_args
|
||||
assert "https://slack.com/api/conversations.list" in call_kwargs.args \
|
||||
or call_kwargs.args[0] == "https://slack.com/api/conversations.list"
|
||||
assert call_kwargs.kwargs["headers"]["Authorization"] == \
|
||||
"Bearer xoxb-test-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_get_uses_search_token_for_search_methods():
|
||||
config = SlackConfig(token="xoxb-bot", search_token="xoxp-user")
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"ok": True,
|
||||
"messages": {
|
||||
"matches": [],
|
||||
},
|
||||
})
|
||||
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.slack._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 slack_get(config,
|
||||
"search.messages",
|
||||
params={"query": "hello"})
|
||||
|
||||
assert result["ok"] is True
|
||||
call_kwargs = mock_session.get.call_args
|
||||
assert call_kwargs.kwargs["headers"]["Authorization"] == \
|
||||
"Bearer xoxp-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_get_error(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"ok": False,
|
||||
"error": "channel_not_found",
|
||||
})
|
||||
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.slack._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=r"\(conversations\.info\): channel_not_found"):
|
||||
await slack_get(config, "conversations.info")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_get_missing_scope_surfaces_scopes(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(
|
||||
return_value={
|
||||
"ok": False,
|
||||
"error": "missing_scope",
|
||||
"needed": "im:read,mpim:read",
|
||||
"provided": "channels:read,users:read",
|
||||
})
|
||||
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.slack._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=(r"\(conversations\.list\): missing_scope "
|
||||
r"\(needed: im:read,mpim:read; "
|
||||
r"provided: channels:read,users:read\)"),
|
||||
):
|
||||
await slack_get(config, "conversations.list")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_get_missing_scope_no_needed_falls_back(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"ok": False,
|
||||
"error": "missing_scope",
|
||||
})
|
||||
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.slack._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) as ei:
|
||||
await slack_get(config, "conversations.list")
|
||||
assert str(ei.value).endswith(
|
||||
"Slack API error (conversations.list): missing_scope")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_post_success(config):
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.json = AsyncMock(return_value={
|
||||
"ok": True,
|
||||
"ts": "1234567890.123456",
|
||||
})
|
||||
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.slack._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 slack_post(config,
|
||||
"chat.postMessage",
|
||||
body={
|
||||
"channel": "C123",
|
||||
"text": "hello",
|
||||
})
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["ts"] == "1234567890.123456"
|
||||
mock_session.post.assert_called_once()
|
||||
call_kwargs = mock_session.post.call_args
|
||||
assert call_kwargs.args[0] == "https://slack.com/api/chat.postMessage"
|
||||
@@ -0,0 +1,186 @@
|
||||
# ========= 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.slack import SlackAccessor
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.core.slack.readdir import readdir
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config):
|
||||
return SlackAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def messages_with_files():
|
||||
return [
|
||||
{
|
||||
"type":
|
||||
"message",
|
||||
"user":
|
||||
"U1",
|
||||
"ts":
|
||||
"1712707200.0",
|
||||
"text":
|
||||
"here's the report",
|
||||
"files": [{
|
||||
"id":
|
||||
"F1ABC",
|
||||
"name":
|
||||
"report.pdf",
|
||||
"title":
|
||||
"report.pdf",
|
||||
"filetype":
|
||||
"pdf",
|
||||
"mimetype":
|
||||
"application/pdf",
|
||||
"size":
|
||||
4096,
|
||||
"url_private_download": ("https://files.slack.com/files-pri"
|
||||
"/T1-F1ABC/download/report.pdf"),
|
||||
"timestamp":
|
||||
1712707200,
|
||||
}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"user": "U2",
|
||||
"ts": "1712707260.0",
|
||||
"text": "no file here",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_dir_listing_from_messages(accessor, index,
|
||||
messages_with_files):
|
||||
await index.set_dir("/channels", [
|
||||
("general__C001",
|
||||
IndexEntry(id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
remote_time="1700000000")),
|
||||
])
|
||||
await index.set_dir("/channels/general__C001", [
|
||||
("2026-04-10",
|
||||
IndexEntry(id="C001:2026-04-10",
|
||||
name="2026-04-10",
|
||||
resource_type="slack/date_dir",
|
||||
vfs_name="2026-04-10")),
|
||||
])
|
||||
with patch("mirage.core.slack.readdir.fetch_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=messages_with_files):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10/files",
|
||||
virtual="/channels/general__C001/2026-04-10/files",
|
||||
directory="/channels/general__C001/2026-04-10/files"),
|
||||
index=index,
|
||||
)
|
||||
assert result == [
|
||||
"/channels/general__C001/2026-04-10/files/report__F1ABC.pdf"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_dir_empty_on_no_attachments(accessor, index):
|
||||
await index.set_dir("/channels", [
|
||||
("general__C001",
|
||||
IndexEntry(id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
remote_time="1700000000")),
|
||||
])
|
||||
await index.set_dir("/channels/general__C001", [
|
||||
("2026-04-10",
|
||||
IndexEntry(id="C001:2026-04-10",
|
||||
name="2026-04-10",
|
||||
resource_type="slack/date_dir",
|
||||
vfs_name="2026-04-10")),
|
||||
])
|
||||
no_file_msgs = [{
|
||||
"type": "message",
|
||||
"user": "U1",
|
||||
"ts": "1712707200.0",
|
||||
"text": "hi"
|
||||
}]
|
||||
with patch("mirage.core.slack.readdir.fetch_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=no_file_msgs):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10/files",
|
||||
virtual="/channels/general__C001/2026-04-10/files",
|
||||
directory="/channels/general__C001/2026-04-10/files"),
|
||||
index=index,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_blob_index_entry_stores_url(accessor, index,
|
||||
messages_with_files):
|
||||
await index.set_dir("/channels", [
|
||||
("general__C001",
|
||||
IndexEntry(id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
remote_time="1700000000")),
|
||||
])
|
||||
await index.set_dir("/channels/general__C001", [
|
||||
("2026-04-10",
|
||||
IndexEntry(id="C001:2026-04-10",
|
||||
name="2026-04-10",
|
||||
resource_type="slack/date_dir",
|
||||
vfs_name="2026-04-10")),
|
||||
])
|
||||
with patch("mirage.core.slack.readdir.fetch_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=messages_with_files):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10/files",
|
||||
virtual="/channels/general__C001/2026-04-10/files",
|
||||
directory="/channels/general__C001/2026-04-10/files"),
|
||||
index=index,
|
||||
)
|
||||
blob = await index.get(
|
||||
"/channels/general__C001/2026-04-10/files/report__F1ABC.pdf")
|
||||
assert blob.entry is not None
|
||||
assert blob.entry.id == "F1ABC"
|
||||
assert blob.entry.size == 4096
|
||||
assert blob.entry.extra["mimetype"] == "application/pdf"
|
||||
assert "url_private_download" in blob.entry.extra
|
||||
assert blob.entry.extra["filetype"] == "pdf"
|
||||
assert blob.entry.extra["ts"] == "1712707200.0"
|
||||
@@ -0,0 +1,71 @@
|
||||
# ========= 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.slack import SlackAccessor
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.core.slack.glob import resolve_glob
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return SlackAccessor(config=SlackConfig(token="xoxb"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_glob_files_pdf(accessor, index):
|
||||
await index.set_dir("/channels/general__C001/2026-04-10/files", [
|
||||
("a__F1.pdf",
|
||||
IndexEntry(id="F1",
|
||||
name="a",
|
||||
resource_type="slack/file",
|
||||
vfs_name="a__F1.pdf",
|
||||
extra={
|
||||
"mimetype": "application/pdf",
|
||||
"url_private_download": "u",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10"
|
||||
})),
|
||||
("b__F2.txt",
|
||||
IndexEntry(id="F2",
|
||||
name="b",
|
||||
resource_type="slack/file",
|
||||
vfs_name="b__F2.txt",
|
||||
extra={
|
||||
"mimetype": "text/plain",
|
||||
"url_private_download": "u",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10"
|
||||
})),
|
||||
])
|
||||
spec = PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/channels/general__C001/2026-04-10/files/*.pdf", ""),
|
||||
virtual="/channels/general__C001/2026-04-10/files/*.pdf",
|
||||
directory="/channels/general__C001/2026-04-10/files/",
|
||||
pattern="*.pdf",
|
||||
resolved=False,
|
||||
)
|
||||
matched = await resolve_glob(accessor, [spec], index=index)
|
||||
assert len(matched) == 1
|
||||
assert matched[0].virtual.endswith("a__F1.pdf")
|
||||
@@ -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. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.slack.history import get_history_jsonl
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_jsonl(config):
|
||||
mock_data = {
|
||||
"ok":
|
||||
True,
|
||||
"messages": [
|
||||
{
|
||||
"text": "second",
|
||||
"ts": "1700000002.000000"
|
||||
},
|
||||
{
|
||||
"text": "first",
|
||||
"ts": "1700000001.000000"
|
||||
},
|
||||
],
|
||||
"has_more":
|
||||
False,
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await get_history_jsonl(config, "C001", "2023-11-14")
|
||||
|
||||
lines = result.decode().strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
first = json.loads(lines[0])
|
||||
second = json.loads(lines[1])
|
||||
assert first["text"] == "first"
|
||||
assert second["text"] == "second"
|
||||
assert float(first["ts"]) < float(second["ts"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_empty(config):
|
||||
mock_data = {
|
||||
"ok": True,
|
||||
"messages": [],
|
||||
"has_more": False,
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await get_history_jsonl(config, "C001", "2023-11-14")
|
||||
|
||||
assert result == b""
|
||||
@@ -0,0 +1,101 @@
|
||||
# ========= 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.core.slack.history import (fetch_messages_for_day,
|
||||
stream_messages_for_day)
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_messages_for_day_applies_day_bounds_and_yields_pages():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"messages": [{
|
||||
"ts": "1.0",
|
||||
"text": "a"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
},
|
||||
},
|
||||
{
|
||||
"messages": [{
|
||||
"ts": "2.0",
|
||||
"text": "b"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
async def fake_get(_cfg, method, params=None, token=None):
|
||||
assert method == "conversations.history"
|
||||
calls.append(dict(params or {}))
|
||||
return pages[len(calls) - 1]
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
seen = []
|
||||
async for page in stream_messages_for_day(cfg, "C1", "2026-05-10"):
|
||||
seen.append(page)
|
||||
|
||||
assert [m["text"] for m in seen[0]] == ["a"]
|
||||
assert [m["text"] for m in seen[1]] == ["b"]
|
||||
assert calls[0]["channel"] == "C1"
|
||||
assert calls[0]["inclusive"] == "true"
|
||||
assert "oldest" in calls[0]
|
||||
assert "latest" in calls[0]
|
||||
assert calls[1]["cursor"] == "cur1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_messages_for_day_collects_and_sorts_across_pages():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"messages": [{
|
||||
"ts": "3.0"
|
||||
}, {
|
||||
"ts": "1.0"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
},
|
||||
},
|
||||
{
|
||||
"messages": [{
|
||||
"ts": "2.0"
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
},
|
||||
]
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_get(_cfg, _method, params=None, token=None):
|
||||
page = pages[calls["n"]]
|
||||
calls["n"] += 1
|
||||
return page
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
result = await fetch_messages_for_day(cfg, "C1", "2026-05-10")
|
||||
|
||||
assert [m["ts"] for m in result] == ["1.0", "2.0", "3.0"]
|
||||
@@ -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 patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.slack.paginate import cursor_pages
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cursor_pages_walks_until_empty_cursor():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"items": [1, 2],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"items": [3],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
}
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
async def fake_get(_cfg, _method, params=None, token=None):
|
||||
calls.append(dict(params or {}))
|
||||
return pages[len(calls) - 1]
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
result = []
|
||||
async for page in cursor_pages(cfg,
|
||||
"conversations.list",
|
||||
base_params={
|
||||
"types": "x",
|
||||
"limit": 100
|
||||
},
|
||||
items_key="items"):
|
||||
result.append(page)
|
||||
assert result == [[1, 2], [3]]
|
||||
assert calls[0] == {"types": "x", "limit": 100}
|
||||
assert calls[1] == {"types": "x", "limit": 100, "cursor": "cur1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cursor_pages_propagates_cancellation():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"items": [1],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"items": [2],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"items": [3],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
}
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
async def fake_get(_cfg, _method, params=None, token=None):
|
||||
calls.append(dict(params or {}))
|
||||
return pages[len(calls) - 1]
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
gen = cursor_pages(cfg,
|
||||
"conversations.list",
|
||||
base_params={"limit": 1},
|
||||
items_key="items")
|
||||
first = await gen.__anext__()
|
||||
await gen.aclose()
|
||||
assert first == [1]
|
||||
assert len(calls) == 1
|
||||
@@ -0,0 +1,212 @@
|
||||
# ========= 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.slack import SlackAccessor
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.core.slack.read import read
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config):
|
||||
return SlackAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
async def _populate_index(index: RAMIndexCacheStore) -> RAMIndexCacheStore:
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
await index.set_dir("/users", [
|
||||
(
|
||||
"alice.json",
|
||||
IndexEntry(
|
||||
id="U001",
|
||||
name="alice",
|
||||
resource_type="slack/user",
|
||||
vfs_name="alice.json",
|
||||
),
|
||||
),
|
||||
])
|
||||
return index
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_jsonl(accessor, index):
|
||||
await _populate_index(index)
|
||||
history_bytes = b'{"text":"hello","ts":"1700000001"}\n'
|
||||
with patch(
|
||||
"mirage.core.slack.read.get_history_jsonl",
|
||||
new_callable=AsyncMock,
|
||||
return_value=history_bytes,
|
||||
) as mock_hist:
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=("/channels/general__C001/2023-11-14/chat.jsonl"
|
||||
).strip("/"),
|
||||
virtual="/channels/general__C001/2023-11-14/chat.jsonl",
|
||||
directory="/channels/general__C001/2023-11-14/chat.jsonl"),
|
||||
index=index)
|
||||
|
||||
assert result == history_bytes
|
||||
mock_hist.assert_called_once_with(accessor.config, "C001", "2023-11-14")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_blob(accessor, index):
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
await index.set_dir(
|
||||
"/channels/general__C001/2026-04-10/files",
|
||||
[
|
||||
(
|
||||
"report__F1.pdf",
|
||||
IndexEntry(
|
||||
id="F1",
|
||||
name="report.pdf",
|
||||
resource_type="slack/file",
|
||||
vfs_name="report__F1.pdf",
|
||||
size=4096,
|
||||
extra={
|
||||
"mimetype": "application/pdf",
|
||||
"url_private_download":
|
||||
"https://files.slack.com/x/report.pdf",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10",
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
with patch("mirage.core.slack.files.download_file",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b"%PDF-1.4 fake bytes"):
|
||||
data = await read(
|
||||
accessor,
|
||||
PathSpec(resource_path=("channels/general__C001/2026-04-10"
|
||||
"/files/report__F1.pdf"),
|
||||
virtual=("/channels/general__C001/2026-04-10"
|
||||
"/files/report__F1.pdf"),
|
||||
directory=("/channels/general__C001/2026-04-10"
|
||||
"/files/report__F1.pdf")),
|
||||
index=index,
|
||||
)
|
||||
assert data == b"%PDF-1.4 fake bytes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_user_json(accessor, index):
|
||||
await _populate_index(index)
|
||||
user_data = {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.read.get_user_profile",
|
||||
new_callable=AsyncMock,
|
||||
return_value=user_data,
|
||||
):
|
||||
result = await read(accessor,
|
||||
PathSpec(resource_path="users/alice.json",
|
||||
virtual="/users/alice.json",
|
||||
directory="/users/alice.json"),
|
||||
index=index)
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["id"] == "U001"
|
||||
assert parsed["name"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_not_found(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor,
|
||||
PathSpec(resource_path="nonexistent/path",
|
||||
virtual="/nonexistent/path",
|
||||
directory="/nonexistent/path"),
|
||||
index=index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_uses_bot_token():
|
||||
from mirage.core.slack.files import download_file
|
||||
seen: list[dict] = []
|
||||
|
||||
class _Resp:
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
async def read(self):
|
||||
return b"OK"
|
||||
|
||||
class _Sess:
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
def get(self, url, headers=None):
|
||||
seen.append(headers)
|
||||
return _Resp()
|
||||
|
||||
with patch("mirage.core.slack.files.aiohttp.ClientSession", _Sess):
|
||||
await download_file(
|
||||
SlackConfig(token="xoxb-bot", search_token="xoxp-user"),
|
||||
"http://x")
|
||||
await download_file(SlackConfig(token="xoxb-bot"), "http://x")
|
||||
assert seen[0] == {"Authorization": "Bearer xoxb-bot"}
|
||||
assert seen[1] == {"Authorization": "Bearer xoxb-bot"}
|
||||
@@ -0,0 +1,274 @@
|
||||
# ========= 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, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.slack import SlackAccessor
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.core.slack.readdir import _date_range, readdir
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config):
|
||||
return SlackAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
result = await readdir(accessor,
|
||||
PathSpec(resource_path="",
|
||||
virtual="/",
|
||||
directory="/"),
|
||||
index=index)
|
||||
assert result == ["/channels", "/dms", "/users"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channels(accessor, index):
|
||||
channels = [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "general"
|
||||
},
|
||||
{
|
||||
"id": "C002",
|
||||
"name": "random"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.slack.readdir.list_channels",
|
||||
new_callable=AsyncMock,
|
||||
return_value=channels,
|
||||
):
|
||||
result = await readdir(accessor,
|
||||
PathSpec(resource_path="channels",
|
||||
virtual="/channels",
|
||||
directory="/channels"),
|
||||
index=index)
|
||||
|
||||
assert "/channels/general__C001" in result
|
||||
assert "/channels/random__C002" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_users(accessor, index):
|
||||
users = [
|
||||
{
|
||||
"id": "U001",
|
||||
"name": "alice"
|
||||
},
|
||||
{
|
||||
"id": "U002",
|
||||
"name": "bob"
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.slack.readdir.list_users",
|
||||
new_callable=AsyncMock,
|
||||
return_value=users,
|
||||
):
|
||||
result = await readdir(accessor,
|
||||
PathSpec(resource_path="users",
|
||||
virtual="/users",
|
||||
directory="/users"),
|
||||
index=index)
|
||||
|
||||
assert "/users/alice__U001.json" in result
|
||||
assert "/users/bob__U002.json" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channel_dates(accessor, index):
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
with patch("mirage.core.slack.readdir._latest_message_ts",
|
||||
new_callable=AsyncMock,
|
||||
return_value=now.timestamp()):
|
||||
result = await readdir(accessor,
|
||||
PathSpec(resource_path="channels/general__C001",
|
||||
virtual="/channels/general__C001",
|
||||
directory="/channels/general__C001"),
|
||||
index=index)
|
||||
|
||||
assert len(result) >= 1
|
||||
assert all(not r.endswith(".jsonl") for r in result)
|
||||
assert all(r.startswith("/channels/general__C001/") for r in result)
|
||||
assert result[0].endswith(now.strftime('%Y-%m-%d'))
|
||||
|
||||
|
||||
def test_date_range_recent():
|
||||
now = datetime.now(timezone.utc)
|
||||
latest = now.timestamp()
|
||||
created = int(now.timestamp()) - 86400 * 5
|
||||
dates = _date_range(latest, created)
|
||||
assert len(dates) == 6
|
||||
assert dates[0] == now.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def test_date_range_capped_at_90():
|
||||
now = datetime.now(timezone.utc)
|
||||
dates = _date_range(now.timestamp(), 1000000000)
|
||||
assert len(dates) == 90
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channels_stores_created(accessor, index):
|
||||
channels = [
|
||||
{
|
||||
"id": "C001",
|
||||
"name": "general",
|
||||
"created": 1700000000
|
||||
},
|
||||
]
|
||||
with patch(
|
||||
"mirage.core.slack.readdir.list_channels",
|
||||
new_callable=AsyncMock,
|
||||
return_value=channels,
|
||||
):
|
||||
await readdir(accessor,
|
||||
PathSpec(resource_path="channels",
|
||||
virtual="/channels",
|
||||
directory="/channels"),
|
||||
index=index)
|
||||
lookup = await index.get("/channels/general__C001")
|
||||
assert lookup.entry is not None
|
||||
assert lookup.entry.remote_time == "1700000000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channel_dates_with_created(accessor, index):
|
||||
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
remote_time="1700000000",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
now = datetime.now(timezone.utc)
|
||||
with patch("mirage.core.slack.readdir._latest_message_ts",
|
||||
new_callable=AsyncMock,
|
||||
return_value=now.timestamp()):
|
||||
result = await readdir(accessor,
|
||||
PathSpec(resource_path="channels/general__C001",
|
||||
virtual="/channels/general__C001",
|
||||
directory="/channels/general__C001"),
|
||||
index=index)
|
||||
assert len(result) == 90
|
||||
assert all(not r.endswith(".jsonl") for r in result)
|
||||
assert result[0].endswith(now.strftime('%Y-%m-%d'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channel_dates_cached_in_entries(accessor, index):
|
||||
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
remote_time="1700000000",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
now = datetime.now(timezone.utc)
|
||||
with patch("mirage.core.slack.readdir._latest_message_ts",
|
||||
new_callable=AsyncMock,
|
||||
return_value=now.timestamp()):
|
||||
await readdir(accessor,
|
||||
PathSpec(resource_path="channels/general__C001",
|
||||
virtual="/channels/general__C001",
|
||||
directory="/channels/general__C001"),
|
||||
index=index)
|
||||
listing = await index.list_dir("/channels/general__C001")
|
||||
assert listing.entries is not None
|
||||
assert len(listing.entries) == 90
|
||||
assert all(not e.endswith(".jsonl") for e in listing.entries)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_date_dir_returns_chat_and_files(accessor, index):
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
vfs_name="general__C001",
|
||||
remote_time="1700000000",
|
||||
),
|
||||
),
|
||||
])
|
||||
await index.set_dir("/channels/general__C001", [
|
||||
(
|
||||
"2026-04-10",
|
||||
IndexEntry(
|
||||
id="C001:2026-04-10",
|
||||
name="2026-04-10",
|
||||
resource_type="slack/date_dir",
|
||||
vfs_name="2026-04-10",
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
with patch("mirage.core.slack.readdir.fetch_messages_for_day",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[]):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10",
|
||||
virtual="/channels/general__C001/2026-04-10",
|
||||
directory="/channels/general__C001/2026-04-10"),
|
||||
index=index,
|
||||
)
|
||||
|
||||
assert sorted(result) == [
|
||||
"/channels/general__C001/2026-04-10/chat.jsonl",
|
||||
"/channels/general__C001/2026-04-10/files",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
# ========= 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.slack import SlackAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.core.slack.readdir import _fetch_day, _latest_message_ts, readdir
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore(ttl=600)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latest_message_ts_returns_none_on_not_in_channel(config):
|
||||
err = RuntimeError(
|
||||
"Slack API error (conversations.history): not_in_channel")
|
||||
with patch("mirage.core.slack.readdir.slack_get",
|
||||
new=AsyncMock(side_effect=err)):
|
||||
result = await _latest_message_ts(config, "C_INACCESSIBLE")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latest_message_ts_returns_none_on_missing_scope(config):
|
||||
err = RuntimeError(
|
||||
"Slack API error (conversations.history): missing_scope "
|
||||
"(needed: channels:history; provided: channels:read)")
|
||||
with patch("mirage.core.slack.readdir.slack_get",
|
||||
new=AsyncMock(side_effect=err)):
|
||||
result = await _latest_message_ts(config, "C_NO_SCOPE")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latest_message_ts_reraises_unrelated_errors(config):
|
||||
err = RuntimeError("Slack API error (conversations.history): rate_limited")
|
||||
with patch("mirage.core.slack.readdir.slack_get",
|
||||
new=AsyncMock(side_effect=err)):
|
||||
with pytest.raises(RuntimeError, match="rate_limited"):
|
||||
await _latest_message_ts(config, "C1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_day_seals_empty_dir_on_not_in_channel(config, index):
|
||||
err = RuntimeError(
|
||||
"Slack API error (conversations.history): not_in_channel")
|
||||
accessor = SlackAccessor(config=config)
|
||||
|
||||
async def fake_history(_cfg, channel_id, date_str):
|
||||
raise err
|
||||
|
||||
with patch("mirage.core.slack.readdir.fetch_messages_for_day",
|
||||
new=fake_history):
|
||||
await _fetch_day(accessor, "C_INACCESSIBLE", "2026-05-10",
|
||||
"/slack/channels/foo__C_INACCESSIBLE/2026-05-10",
|
||||
index)
|
||||
listing = await index.list_dir(
|
||||
"/slack/channels/foo__C_INACCESSIBLE/2026-05-10")
|
||||
assert listing.entries == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_channel_inaccessible_yields_no_dates(config, index):
|
||||
"""Full integration: ls /slack/channels/inaccessible/ → no dates."""
|
||||
accessor = SlackAccessor(config=config)
|
||||
channels_page = {
|
||||
"channels": [{
|
||||
"id": "C_INACCESSIBLE",
|
||||
"name": "private",
|
||||
"created": 1
|
||||
}],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
}
|
||||
err = RuntimeError(
|
||||
"Slack API error (conversations.history): not_in_channel")
|
||||
|
||||
async def fake_get(_cfg, method, params=None, token=None):
|
||||
if method == "conversations.list":
|
||||
return channels_page
|
||||
if method == "conversations.history":
|
||||
raise err
|
||||
raise AssertionError(f"unexpected {method}")
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get), \
|
||||
patch("mirage.core.slack.readdir.slack_get", new=fake_get):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("/slack/channels", "/slack"),
|
||||
virtual="/slack/channels",
|
||||
directory="/slack/channels/"),
|
||||
index,
|
||||
)
|
||||
dates = await readdir(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/slack/channels/private__C_INACCESSIBLE", "/slack"),
|
||||
virtual="/slack/channels/private__C_INACCESSIBLE",
|
||||
directory="/slack/channels/private__C_INACCESSIBLE/",
|
||||
),
|
||||
index,
|
||||
)
|
||||
assert dates == []
|
||||
@@ -0,0 +1,223 @@
|
||||
# ========= 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.slack.scope import coalesce_scopes, detect_scope
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _gs(path: str,
|
||||
prefix: str = "",
|
||||
pattern: str | None = None,
|
||||
directory: str | None = None) -> PathSpec:
|
||||
return PathSpec(
|
||||
resource_path=mount_key(path, prefix),
|
||||
virtual=path,
|
||||
directory=directory
|
||||
or (path.rsplit("/", 1)[0] + "/" if "/" in path else "/"),
|
||||
pattern=pattern,
|
||||
)
|
||||
|
||||
|
||||
def test_root_empty():
|
||||
scope = detect_scope("/")
|
||||
assert scope.use_native is True
|
||||
assert scope.channel_name is None
|
||||
|
||||
|
||||
def test_root_with_prefix():
|
||||
scope = detect_scope(_gs("/slack/", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
|
||||
|
||||
def test_channels_root():
|
||||
scope = detect_scope(_gs("/slack/channels", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.container == "channels"
|
||||
assert scope.channel_name is None
|
||||
|
||||
|
||||
def test_channel_dir():
|
||||
scope = detect_scope(_gs("/slack/channels/general__C1", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.container == "channels"
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_channel_dm_dir():
|
||||
scope = detect_scope(_gs("/slack/dms/alice__D1", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.container == "dms"
|
||||
assert scope.channel_name == "alice"
|
||||
assert scope.channel_id == "D1"
|
||||
|
||||
|
||||
def test_channel_glob_jsonl():
|
||||
spec = PathSpec(
|
||||
resource_path=mount_key("/slack/channels/general__C1/*/chat.jsonl",
|
||||
"/slack"),
|
||||
virtual="/slack/channels/general__C1/*/chat.jsonl",
|
||||
directory="/slack/channels/general__C1/",
|
||||
pattern="*/chat.jsonl",
|
||||
resolved=False,
|
||||
)
|
||||
scope = detect_scope(spec)
|
||||
assert scope.use_native is True
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_specific_chat_jsonl():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10/chat.jsonl",
|
||||
prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
assert scope.date_str == "2026-04-10"
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_users_dir_not_native():
|
||||
scope = detect_scope(_gs("/slack/users", prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
|
||||
|
||||
def test_users_file_not_native():
|
||||
scope = detect_scope(_gs("/slack/users/alice__U1.json", prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
|
||||
|
||||
def test_unknown_root_not_native():
|
||||
scope = detect_scope(_gs("/slack/whatever", prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
|
||||
|
||||
def test_dirname_without_id():
|
||||
scope = detect_scope(_gs("/slack/channels/general", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id is None
|
||||
|
||||
|
||||
def _spec(path: str, prefix: str = "/slack") -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key(path, prefix),
|
||||
virtual=path,
|
||||
directory=path)
|
||||
|
||||
|
||||
def test_coalesce_concrete_jsonl_paths_same_channel():
|
||||
paths = [
|
||||
_spec(f"/slack/channels/general__C1/2026-01-{day:02d}/chat.jsonl")
|
||||
for day in range(1, 8)
|
||||
]
|
||||
scope = coalesce_scopes(paths)
|
||||
assert scope is not None
|
||||
assert scope.use_native is True
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.container == "channels"
|
||||
|
||||
|
||||
def test_coalesce_returns_none_for_mixed_channels():
|
||||
paths = [
|
||||
_spec("/slack/channels/general__C1/2026-01-01/chat.jsonl"),
|
||||
_spec("/slack/channels/random__C2/2026-01-01/chat.jsonl"),
|
||||
]
|
||||
assert coalesce_scopes(paths) is None
|
||||
|
||||
|
||||
def test_coalesce_returns_none_for_mixed_containers():
|
||||
paths = [
|
||||
_spec("/slack/channels/general__C1/2026-01-01/chat.jsonl"),
|
||||
_spec("/slack/dms/alice__D1/2026-01-01/chat.jsonl"),
|
||||
]
|
||||
assert coalesce_scopes(paths) is None
|
||||
|
||||
|
||||
def test_coalesce_single_path_delegates_to_detect_scope():
|
||||
p = _spec("/slack/channels/general__C1/2026-01-01/chat.jsonl")
|
||||
scope = coalesce_scopes([p])
|
||||
assert scope is not None
|
||||
assert scope.use_native is True
|
||||
assert scope.channel_name == "general"
|
||||
|
||||
|
||||
def test_coalesce_empty_list_returns_none():
|
||||
assert coalesce_scopes([]) is None
|
||||
|
||||
|
||||
def test_date_dir():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.date_str == "2026-04-10"
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
assert scope.target == "date"
|
||||
|
||||
|
||||
def test_files_dir():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10/files", prefix="/slack"))
|
||||
assert scope.use_native is True
|
||||
assert scope.date_str == "2026-04-10"
|
||||
assert scope.target == "files"
|
||||
|
||||
|
||||
def test_specific_file_blob():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10/files/report__F1.pdf",
|
||||
prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
assert scope.date_str == "2026-04-10"
|
||||
assert scope.target == "files"
|
||||
assert scope.channel_name == "general"
|
||||
assert scope.channel_id == "C1"
|
||||
|
||||
|
||||
def test_glob_files_in_day():
|
||||
spec = PathSpec(
|
||||
resource_path=mount_key(
|
||||
"/slack/channels/general__C1/2026-04-10/files/*.pdf", "/slack"),
|
||||
virtual="/slack/channels/general__C1/2026-04-10/files/*.pdf",
|
||||
directory="/slack/channels/general__C1/2026-04-10/files/",
|
||||
pattern="*.pdf",
|
||||
resolved=False,
|
||||
)
|
||||
scope = detect_scope(spec)
|
||||
assert scope.use_native is True
|
||||
assert scope.target == "files"
|
||||
|
||||
|
||||
def test_chat_jsonl_target():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10/chat.jsonl",
|
||||
prefix="/slack"))
|
||||
assert scope.target == "messages"
|
||||
|
||||
|
||||
def test_depth3_non_date_falls_through():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/notadate", prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
assert scope.target is None
|
||||
|
||||
|
||||
def test_depth4_unknown_leaf_under_date_not_native():
|
||||
scope = detect_scope(
|
||||
_gs("/slack/channels/general__C1/2026-04-10/random.txt",
|
||||
prefix="/slack"))
|
||||
assert scope.use_native is False
|
||||
assert scope.target is 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. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.slack.formatters import format_grep_results
|
||||
from mirage.core.slack.scope import SlackScope
|
||||
from mirage.core.slack.search import search_messages
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_defaults_include_page_1():
|
||||
cfg = SlackConfig(token="xoxp-test")
|
||||
with patch(
|
||||
"mirage.core.slack.search.slack_get",
|
||||
new=AsyncMock(return_value={"ok": True}),
|
||||
) as fake_get:
|
||||
await search_messages(cfg, "hello")
|
||||
params = fake_get.call_args.kwargs["params"]
|
||||
assert params["query"] == "hello"
|
||||
assert params["count"] == 20
|
||||
assert params["page"] == 1
|
||||
assert params["sort"] == "timestamp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_messages_forwards_explicit_count_and_page():
|
||||
cfg = SlackConfig(token="xoxp-test")
|
||||
with patch(
|
||||
"mirage.core.slack.search.slack_get",
|
||||
new=AsyncMock(return_value={"ok": True}),
|
||||
) as fake_get:
|
||||
await search_messages(cfg, "hello", count=50, page=3)
|
||||
params = fake_get.call_args.kwargs["params"]
|
||||
assert params["count"] == 50
|
||||
assert params["page"] == 3
|
||||
|
||||
|
||||
def test_format_grep_results_path_uses_chat_jsonl():
|
||||
raw_payload = {
|
||||
"messages": {
|
||||
"matches": [
|
||||
{
|
||||
"channel": {
|
||||
"id": "C001",
|
||||
"name": "general"
|
||||
},
|
||||
"user": "U1",
|
||||
"ts": "1712707200.0",
|
||||
"text": "hello",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
raw = json.dumps(raw_payload).encode()
|
||||
scope = SlackScope(
|
||||
use_native=True,
|
||||
container="channels",
|
||||
channel_name="general",
|
||||
channel_id="C001",
|
||||
target="messages",
|
||||
)
|
||||
lines = format_grep_results(raw, scope, "/slack")
|
||||
assert len(lines) == 1
|
||||
line = lines[0]
|
||||
assert line.startswith(
|
||||
"/slack/channels/general__C001/2024-04-10/chat.jsonl:"), line
|
||||
@@ -0,0 +1,157 @@
|
||||
# ========= 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.slack.formatters import build_query, format_file_grep_results
|
||||
from mirage.core.slack.scope import SlackScope
|
||||
from mirage.core.slack.search import search_files
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_files_calls_correct_endpoint():
|
||||
config = SlackConfig(token="xoxb", search_token="xoxp")
|
||||
fake_response = {
|
||||
"ok": True,
|
||||
"files": {
|
||||
"matches": []
|
||||
},
|
||||
}
|
||||
with patch("mirage.core.slack.search.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_response) as mock:
|
||||
await search_files(config, "report")
|
||||
args, kwargs = mock.call_args
|
||||
assert args[1] == "search.files"
|
||||
assert kwargs["params"]["query"] == "report"
|
||||
assert "token" not in kwargs
|
||||
|
||||
|
||||
def test_format_file_grep_results_renders_paths():
|
||||
raw_payload = {
|
||||
"files": {
|
||||
"matches": [
|
||||
{
|
||||
"id": "F1ABC",
|
||||
"name": "report.pdf",
|
||||
"title": "Q4 Report",
|
||||
"filetype": "pdf",
|
||||
"channels": ["C001"],
|
||||
"timestamp": 1712707200,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
raw = json.dumps(raw_payload).encode()
|
||||
scope = SlackScope(use_native=True,
|
||||
container="channels",
|
||||
channel_name="general",
|
||||
channel_id="C001",
|
||||
target="files")
|
||||
lines = format_file_grep_results(raw, scope, "/slack")
|
||||
assert len(lines) == 1
|
||||
line = lines[0]
|
||||
assert "files/" in line
|
||||
assert "F1ABC" in line
|
||||
assert "[file]" in line
|
||||
assert "Q4 Report" in line
|
||||
|
||||
|
||||
def test_build_query_unchanged_for_files():
|
||||
scope = SlackScope(use_native=True,
|
||||
container="channels",
|
||||
channel_name="eng",
|
||||
channel_id="C1",
|
||||
target="files")
|
||||
assert build_query("foo", scope) == "in:#eng foo"
|
||||
|
||||
|
||||
def test_format_file_grep_results_emits_exact_path():
|
||||
raw_payload = {
|
||||
"files": {
|
||||
"matches": [{
|
||||
"id": "F1ABC",
|
||||
"name": "report.pdf",
|
||||
"title": "Q4 Report",
|
||||
"filetype": "pdf",
|
||||
"channels": ["C001"],
|
||||
"timestamp": 1712707200,
|
||||
}],
|
||||
},
|
||||
}
|
||||
raw = json.dumps(raw_payload).encode()
|
||||
scope = SlackScope(use_native=True,
|
||||
container="channels",
|
||||
channel_name="general",
|
||||
channel_id="C001",
|
||||
target="files")
|
||||
lines = format_file_grep_results(raw, scope, "/slack")
|
||||
assert len(lines) == 1
|
||||
expected_path = ("/slack/channels/general__C001/2024-04-10/files/"
|
||||
"report__F1ABC.pdf")
|
||||
assert lines[0].startswith(expected_path + ":"), lines[0]
|
||||
|
||||
|
||||
def test_format_file_grep_results_skips_when_no_scope_channel():
|
||||
raw_payload = {
|
||||
"files": {
|
||||
"matches": [{
|
||||
"id": "F1ABC",
|
||||
"name": "report.pdf",
|
||||
"title": "Q4 Report",
|
||||
"channels": ["C001"],
|
||||
"timestamp": 1712707200,
|
||||
}],
|
||||
},
|
||||
}
|
||||
raw = json.dumps(raw_payload).encode()
|
||||
scope = SlackScope(use_native=True,
|
||||
container="channels",
|
||||
channel_name=None,
|
||||
channel_id=None,
|
||||
target="files")
|
||||
lines = format_file_grep_results(raw, scope, "/slack")
|
||||
assert lines == []
|
||||
|
||||
|
||||
def test_format_file_grep_results_preserves_special_chars():
|
||||
raw_payload = {
|
||||
"files": {
|
||||
"matches": [{
|
||||
"id": "F1ABC",
|
||||
"name": "Q4 Report (final).pdf",
|
||||
"title": "Q4",
|
||||
"channels": ["C001"],
|
||||
"timestamp": 1712707200,
|
||||
}],
|
||||
},
|
||||
}
|
||||
raw = json.dumps(raw_payload).encode()
|
||||
scope = SlackScope(
|
||||
use_native=True,
|
||||
container="channels",
|
||||
channel_name="general",
|
||||
channel_id="C001",
|
||||
target="files",
|
||||
)
|
||||
lines = format_file_grep_results(raw, scope, "/slack")
|
||||
assert len(lines) == 1
|
||||
line = lines[0]
|
||||
assert "F1ABC" in line
|
||||
blob_segment = line.split("/files/")[1].split(":")[0]
|
||||
assert blob_segment == "Q4 Report (final)__F1ABC.pdf"
|
||||
@@ -0,0 +1,256 @@
|
||||
# ========= 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.slack import SlackAccessor
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
from mirage.core.slack.stat import stat
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor(config):
|
||||
return SlackAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
async def _populate_index(index: RAMIndexCacheStore) -> None:
|
||||
await index.set_dir("/channels", [
|
||||
(
|
||||
"general__C001",
|
||||
IndexEntry(
|
||||
id="C001",
|
||||
name="general",
|
||||
resource_type="slack/channel",
|
||||
remote_time="1609459200",
|
||||
vfs_name="general__C001",
|
||||
),
|
||||
),
|
||||
])
|
||||
await index.set_dir("/users", [
|
||||
(
|
||||
"alice.json",
|
||||
IndexEntry(
|
||||
id="U001",
|
||||
name="alice",
|
||||
resource_type="slack/user",
|
||||
vfs_name="alice.json",
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(accessor, index):
|
||||
result = await stat(accessor,
|
||||
PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index=index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_channel(accessor, index):
|
||||
await _populate_index(index)
|
||||
result = await stat(accessor,
|
||||
PathSpec(resource_path="channels/general__C001",
|
||||
virtual="/channels/general__C001",
|
||||
directory="/channels/general__C001"),
|
||||
index=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_user(accessor, index):
|
||||
await _populate_index(index)
|
||||
result = await stat(accessor,
|
||||
PathSpec(resource_path="users/alice.json",
|
||||
virtual="/users/alice.json",
|
||||
directory="/users/alice.json"),
|
||||
index=index)
|
||||
assert result.type == FileType.JSON
|
||||
assert result.extra["user_id"] == "U001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_jsonl(accessor, index):
|
||||
await _populate_index(index)
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2023-11-14/chat.jsonl",
|
||||
virtual="/channels/general__C001/2023-11-14/chat.jsonl",
|
||||
directory="/channels/general__C001/2023-11-14/chat.jsonl"),
|
||||
index=index)
|
||||
assert result.type == FileType.TEXT
|
||||
assert result.name == "chat.jsonl"
|
||||
|
||||
|
||||
@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=index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_date_dir(accessor, index):
|
||||
await index.set_dir("/channels/general__C001", [
|
||||
("2026-04-10",
|
||||
IndexEntry(id="C001:2026-04-10",
|
||||
name="2026-04-10",
|
||||
resource_type="slack/date_dir",
|
||||
vfs_name="2026-04-10")),
|
||||
])
|
||||
s = await stat(accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10",
|
||||
virtual="/channels/general__C001/2026-04-10",
|
||||
directory="/channels/general__C001/2026-04-10"),
|
||||
index=index)
|
||||
assert s.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_non_date_dir_not_found(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor,
|
||||
PathSpec(resource_path="channels/general__C001/notadate",
|
||||
virtual="/channels/general__C001/notadate",
|
||||
directory="/channels/general__C001/notadate"),
|
||||
index=index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_chat_jsonl(accessor, index):
|
||||
s = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="channels/general__C001/2026-04-10/chat.jsonl",
|
||||
virtual="/channels/general__C001/2026-04-10/chat.jsonl",
|
||||
directory="/channels/general__C001/2026-04-10/chat.jsonl"),
|
||||
index=index)
|
||||
assert s.type == FileType.TEXT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_files_dir(accessor, index):
|
||||
s = await stat(accessor,
|
||||
PathSpec(
|
||||
resource_path="channels/general__C001/2026-04-10/files",
|
||||
virtual="/channels/general__C001/2026-04-10/files",
|
||||
directory="/channels/general__C001/2026-04-10/files"),
|
||||
index=index)
|
||||
assert s.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file_blob_pdf(accessor, index):
|
||||
await index.set_dir("/channels/general__C001/2026-04-10/files", [
|
||||
("report__F1.pdf",
|
||||
IndexEntry(id="F1",
|
||||
name="report",
|
||||
resource_type="slack/file",
|
||||
vfs_name="report__F1.pdf",
|
||||
size=4096,
|
||||
extra={
|
||||
"mimetype": "application/pdf",
|
||||
"url_private_download": "u",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10"
|
||||
})),
|
||||
])
|
||||
s = await stat(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=(
|
||||
"/channels/general__C001/2026-04-10/files/report__F1.pdf"
|
||||
).strip("/"),
|
||||
virtual="/channels/general__C001/2026-04-10/files/report__F1.pdf",
|
||||
directory="/channels/general__C001/2026-04-10/files/report__F1.pdf"
|
||||
),
|
||||
index=index)
|
||||
assert s.type == FileType.PDF
|
||||
assert s.size == 4096
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file_blob_text(accessor, index):
|
||||
await index.set_dir("/channels/general__C001/2026-04-10/files", [
|
||||
("notes__F2.txt",
|
||||
IndexEntry(id="F2",
|
||||
name="notes",
|
||||
resource_type="slack/file",
|
||||
vfs_name="notes__F2.txt",
|
||||
size=128,
|
||||
extra={
|
||||
"mimetype": "text/plain",
|
||||
"url_private_download": "u",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10"
|
||||
})),
|
||||
])
|
||||
s = await stat(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=(
|
||||
"/channels/general__C001/2026-04-10/files/notes__F2.txt"
|
||||
).strip("/"),
|
||||
virtual="/channels/general__C001/2026-04-10/files/notes__F2.txt",
|
||||
directory="/channels/general__C001/2026-04-10/files/notes__F2.txt"
|
||||
),
|
||||
index=index)
|
||||
assert s.type == FileType.TEXT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_file_blob_unknown_mimetype_is_binary(accessor, index):
|
||||
await index.set_dir("/channels/general__C001/2026-04-10/files", [
|
||||
("data__F3.bin",
|
||||
IndexEntry(id="F3",
|
||||
name="data",
|
||||
resource_type="slack/file",
|
||||
vfs_name="data__F3.bin",
|
||||
size=2048,
|
||||
extra={
|
||||
"mimetype": "application/octet-stream",
|
||||
"url_private_download": "u",
|
||||
"channel_id": "C001",
|
||||
"date": "2026-04-10"
|
||||
})),
|
||||
])
|
||||
s = await stat(
|
||||
accessor,
|
||||
PathSpec(
|
||||
resource_path=(
|
||||
"/channels/general__C001/2026-04-10/files/data__F3.bin"
|
||||
).strip("/"),
|
||||
virtual="/channels/general__C001/2026-04-10/files/data__F3.bin",
|
||||
directory="/channels/general__C001/2026-04-10/files/data__F3.bin"),
|
||||
index=index)
|
||||
assert s.type == FileType.BINARY
|
||||
assert s.size == 2048
|
||||
@@ -0,0 +1,181 @@
|
||||
# ========= 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.slack.users import get_user_profile, list_users, search_users
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return SlackConfig(token="xoxb-test-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(config):
|
||||
mock_data = {
|
||||
"ok":
|
||||
True,
|
||||
"members": [
|
||||
{
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
{
|
||||
"id": "U002",
|
||||
"name": "bot-helper",
|
||||
"deleted": False,
|
||||
"is_bot": True
|
||||
},
|
||||
{
|
||||
"id": "U003",
|
||||
"name": "gone",
|
||||
"deleted": True,
|
||||
"is_bot": False
|
||||
},
|
||||
{
|
||||
"id": "USLACKBOT",
|
||||
"name": "slackbot",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
{
|
||||
"id": "U004",
|
||||
"name": "bob",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await list_users(config)
|
||||
|
||||
assert len(result) == 2
|
||||
names = [u["name"] for u in result]
|
||||
assert "alice" in names
|
||||
assert "bob" in names
|
||||
assert "bot-helper" not in names
|
||||
assert "gone" not in names
|
||||
assert "slackbot" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users(config):
|
||||
mock_data = {
|
||||
"ok":
|
||||
True,
|
||||
"members": [
|
||||
{
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"deleted": False,
|
||||
"is_bot": False,
|
||||
"profile": {
|
||||
"email": "alice@example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "U002",
|
||||
"name": "bob",
|
||||
"real_name": "Bob Jones",
|
||||
"deleted": False,
|
||||
"is_bot": False,
|
||||
"profile": {
|
||||
"email": "bob@example.com"
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await search_users(config, "alice")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users_by_email(config):
|
||||
mock_data = {
|
||||
"ok":
|
||||
True,
|
||||
"members": [
|
||||
{
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"deleted": False,
|
||||
"is_bot": False,
|
||||
"profile": {
|
||||
"email": "alice@example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "U002",
|
||||
"name": "bob",
|
||||
"real_name": "Bob Jones",
|
||||
"deleted": False,
|
||||
"is_bot": False,
|
||||
"profile": {
|
||||
"email": "bob@example.com"
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.paginate.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await search_users(config, "bob@example")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "bob"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_profile(config):
|
||||
mock_data = {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": "U001",
|
||||
"name": "alice",
|
||||
"real_name": "Alice Smith",
|
||||
"profile": {
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"mirage.core.slack.users.slack_get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_data,
|
||||
):
|
||||
result = await get_user_profile(config, "U001")
|
||||
|
||||
assert result["id"] == "U001"
|
||||
assert result["name"] == "alice"
|
||||
@@ -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 patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.slack.users import list_users_stream
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_stream_walks_pages_and_filters():
|
||||
cfg = SlackConfig(token="xoxb-t")
|
||||
pages = [
|
||||
{
|
||||
"members": [
|
||||
{
|
||||
"id": "U1",
|
||||
"name": "alice",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
{
|
||||
"id": "U2",
|
||||
"name": "bot",
|
||||
"deleted": False,
|
||||
"is_bot": True
|
||||
},
|
||||
],
|
||||
"response_metadata": {
|
||||
"next_cursor": "cur1"
|
||||
},
|
||||
},
|
||||
{
|
||||
"members": [
|
||||
{
|
||||
"id": "USLACKBOT",
|
||||
"name": "slackbot",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
{
|
||||
"id": "U3",
|
||||
"name": "bob",
|
||||
"deleted": False,
|
||||
"is_bot": False
|
||||
},
|
||||
],
|
||||
"response_metadata": {
|
||||
"next_cursor": ""
|
||||
},
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
async def fake_get(_cfg, method, params=None, token=None):
|
||||
assert method == "users.list"
|
||||
calls.append(dict(params or {}))
|
||||
return pages[len(calls) - 1]
|
||||
|
||||
with patch("mirage.core.slack.paginate.slack_get", new=fake_get):
|
||||
seen = []
|
||||
async for page in list_users_stream(cfg):
|
||||
seen.append(page)
|
||||
|
||||
assert len(seen) == 2
|
||||
assert [u["name"] for u in seen[0]] == ["alice"]
|
||||
assert [u["name"] for u in seen[1]] == ["bob"]
|
||||
assert calls[0]["limit"] == 200
|
||||
assert calls[1]["cursor"] == "cur1"
|
||||
Reference in New Issue
Block a user