Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Python Tests

on:
pull_request:
branches: [ main ]
push:
branches: [ main ]

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
os: [ubuntu-latest]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install pytest pytest-asyncio

- name: Run tests with coverage
run: |
pytest --cov=sailhouse --cov-report=xml
2 changes: 1 addition & 1 deletion src/sailhouse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .client import SailhouseClient
from .client import SailhouseClient, GetEventsResponse, Event
from .exceptions import SailhouseError

__version__ = "0.1.0"
Expand Down
30 changes: 0 additions & 30 deletions src/sailhouse/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,36 +143,6 @@ async def acknowledge_message(
raise SailhouseError(
f"Failed to acknowledge message: {response.status_code}")

@asynccontextmanager
async def stream_events(
self,
topic: str,
subscription: str
):
"""Stream events using websockets"""
uri = "wss://api.sailhouse.dev/events/stream"

async with websockets.connect(uri) as websocket:
await websocket.send(json.dumps({
"topic_slug": topic,
"subscription_slug": subscription,
"token": self.token
}))

while True:
try:
message = await websocket.recv()
event_data = json.loads(message)
yield Event(
id=event_data['id'],
data=event_data['data'],
_topic=topic,
_subscription=subscription,
_client=self
)
except websockets.exceptions.ConnectionClosed:
break

async def subscribe(
self,
topic: str,
Expand Down
229 changes: 229 additions & 0 deletions test/client_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
from dataclasses import dataclass
import pytest
from datetime import datetime
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import json

from sailhouse import SailhouseClient, Event, GetEventsResponse, SailhouseError

# Fixtures


@pytest.fixture
def client():
return SailhouseClient(token="test-token")


@pytest.fixture
def mock_response():
class MockResponse:
def __init__(self, status_code, json_data):
self.status_code = status_code
self._json_data = json_data
self.text = json.dumps(json_data)

def json(self):
return self._json_data

return MockResponse

# Test Event class


def test_event_creation():
client = SailhouseClient(token="test-token")
event = Event(
id="test-id",
data={"message": "test"},
_topic="test-topic",
_subscription="test-sub",
_client=client
)

assert event.id == "test-id"
assert event.data == {"message": "test"}


def test_event_as_type():
@dataclass
class TestType:
message: str

client = SailhouseClient(token="test-token")
event = Event(
id="test-id",
data={"message": "test"},
_topic="test-topic",
_subscription="test-sub",
_client=client
)

converted = event.as_type(TestType)
assert isinstance(converted, TestType)
assert converted.message == "test"

# Test SailhouseClient


def test_client_initialization():
client = SailhouseClient(token="test-token")
assert client.token == "test-token"
assert client.timeout == 5.0
assert client.base_url == "https://api.sailhouse.dev"
assert client.session.headers["Authorization"] == "test-token"
assert client.session.headers["x-source"] == "sailhouse-python"


@pytest.mark.asyncio
async def test_get_events_success(client, mock_response):
test_events = {
"events": [
{"id": "1", "data": {"message": "test1"}},
{"id": "2", "data": {"message": "test2"}}
],
"offset": 0,
"limit": 10
}

with patch.object(client.session, 'get', return_value=mock_response(200, test_events)):
response = await client.get_events("test-topic", "test-sub")

assert isinstance(response, GetEventsResponse)
assert len(response.events) == 2
assert response.offset == 0
assert response.limit == 10
assert isinstance(response.events[0], Event)
assert response.events[0].data["message"] == "test1"


@pytest.mark.asyncio
async def test_get_events_failure(client, mock_response):
with patch.object(client.session, 'get', return_value=mock_response(400, {})):
with pytest.raises(SailhouseError):
await client.get_events("test-topic", "test-sub")


@pytest.mark.asyncio
async def test_publish_success(client, mock_response):
test_data = {"message": "test"}
scheduled_time = datetime.now()
metadata = {"key": "value"}

with patch.object(client.session, 'post', return_value=mock_response(201, {})):
await client.publish(
"test-topic",
test_data,
scheduled_time=scheduled_time,
metadata=metadata
)
# Test passes if no exception is raised


@pytest.mark.asyncio
async def test_publish_failure(client, mock_response):
with patch.object(client.session, 'post', return_value=mock_response(400, {})):
with pytest.raises(SailhouseError):
await client.publish("test-topic", {"message": "test"})


@pytest.mark.asyncio
async def test_acknowledge_message_success(client, mock_response):
with patch.object(client.session, 'post', return_value=mock_response(204, {})):
await client.acknowledge_message("test-topic", "test-sub", "event-id")
# Test passes if no exception is raised


@pytest.mark.asyncio
async def test_acknowledge_message_failure(client, mock_response):
with patch.object(client.session, 'post', return_value=mock_response(400, {})):
with pytest.raises(SailhouseError):
await client.acknowledge_message("test-topic", "test-sub", "event-id")


@pytest.mark.asyncio
async def test_subscribe_handler_called(client, mock_response):
test_events = {
"events": [
{"id": "1", "data": {"message": "test1"}},
],
"offset": 0,
"limit": 10
}

handler = AsyncMock()

# Set up a mock that will return events once then exit
get_events_mock = AsyncMock()
get_events_mock.return_value = GetEventsResponse(
events=[Event(
id="1",
data={"message": "test1"},
_topic="test-topic",
_subscription="test-sub",
_client=client
)],
offset=0,
limit=10
)

with patch.object(client, 'get_events', get_events_mock):
# Create a task for the subscription
task = asyncio.create_task(
client.subscribe(
"test-topic",
"test-sub",
handler,
polling_interval=0.5,
exit_on_error=True # This will make it exit after first iteration
)
)

# Wait a short time to allow the handler to be called
await asyncio.sleep(0.2)

# Cancel the task
task.cancel()

try:
await task
except asyncio.CancelledError:
pass

# Verify handler was called exactly once
handler.assert_called_once()


@pytest.mark.asyncio
async def test_subscribe_with_error_handler(client):
error_handler = AsyncMock()
handler = AsyncMock()

# Create a mock that raises an exception
get_events_mock = AsyncMock(side_effect=Exception("Test error"))

with patch.object(client, 'get_events', get_events_mock):
task = asyncio.create_task(
client.subscribe(
"test-topic",
"test-sub",
handler,
polling_interval=0.5,
on_error=error_handler,
exit_on_error=True
)
)

# Wait a short time for the error handler to be called
await asyncio.sleep(0.2)

# Cancel the task
task.cancel()

try:
await task
except asyncio.CancelledError:
pass

# Verify error handler was called
error_handler.assert_called_once()
Loading