Skip to content

Commit 4cf7197

Browse files
committed
cocalc-api: add tests, using localhost instance and a given key
1 parent ef6b05d commit 4cf7197

File tree

10 files changed

+356
-5
lines changed

10 files changed

+356
-5
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.defaultInterpreterPath": "./.venv/bin/python",
3+
"python.analysis.extraPaths": ["./src"],
4+
"python.analysis.autoSearchPaths": true,
5+
"python.analysis.autoImportCompletions": true,
6+
"pylance.insidersChannel": "off"
7+
}

src/python/cocalc-api/Makefile

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ install:
66
uv pip install -e .
77

88
format:
9-
uv run yapf --in-place --recursive src/
9+
uv run yapf --in-place --recursive src/ tests/
1010

1111
check:
12-
uv run ruff check src/
13-
uv run mypy src/
14-
uv run pyright src/
12+
uv run ruff check src/ tests/
13+
uv run mypy src/ tests/
14+
uv run pyright src/ tests/
15+
16+
test:
17+
uv run pytest
18+
19+
test-verbose:
20+
uv run pytest -v
1521

1622
serve-docs:
1723
uv run mkdocs serve
@@ -24,5 +30,6 @@ publish: install
2430
uv publish
2531

2632
clean:
27-
rm -rf dist build *.egg-info site
33+
rm -rf dist build *.egg-info site .pytest_cache
2834
find . -name "__pycache__" -type d -exec rm -rf {} +
35+
find . -name "*.pyc" -delete
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"include": [
3+
"src",
4+
"tests"
5+
],
6+
"extraPaths": [
7+
"src"
8+
],
9+
"venvPath": ".",
10+
"venv": ".venv",
11+
"pythonVersion": "3.12",
12+
"typeCheckingMode": "basic"
13+
}

src/python/cocalc-api/pytest.ini

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[tool:pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
python_classes = Test*
5+
python_functions = test_*
6+
markers =
7+
integration: marks tests as integration tests (require live server)
8+
addopts =
9+
-v
10+
--tb=short

src/python/cocalc-api/src/cocalc_api/hub.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,4 @@ def message(self, name: str, subject: str, body: str) -> dict[str, Any]:
611611
subject (str): plain text subject of the message
612612
body (str): markdown body of the message (math typesetting works)
613613
"""
614+
raise NotImplementedError
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# CoCalc API Tests
2+
3+
This directory contains pytest tests for the cocalc-api Python package.
4+
5+
## Prerequisites
6+
7+
1. **Required**: Set the `COCALC_API_KEY` environment variable with a valid CoCalc API key (tests will fail if not set)
8+
2. Optionally set `COCALC_HOST` to specify the CoCalc server URL (defaults to `http://localhost:5000`)
9+
10+
## Running Tests
11+
12+
```bash
13+
# Run all tests
14+
make test
15+
16+
# Run tests with verbose output
17+
make test-verbose
18+
19+
# Or use pytest directly
20+
uv run pytest
21+
uv run pytest -v
22+
```
23+
24+
## Test Structure
25+
26+
- `conftest.py` - Pytest configuration and fixtures (includes temporary project creation)
27+
- `test_hub.py` - Tests for Hub client functionality including project creation
28+
- `test_project.py` - Tests for Project client functionality using auto-created temporary projects
29+
30+
## Test Markers
31+
32+
- `@pytest.mark.integration` - Marks tests that require a live CoCalc server
33+
34+
## Environment Variables
35+
36+
- `COCALC_API_KEY` (required) - Your CoCalc API key
37+
- `COCALC_HOST` (optional) - CoCalc server URL (default: `http://localhost:5000`)
38+
39+
## Automatic Project Creation
40+
41+
The test suite automatically creates temporary projects for testing via the `temporary_project` fixture:
42+
43+
- Projects are created with unique names like `CoCalc API Test YYYYMMDD-HHMMSS`
44+
- Projects include a description: "Temporary project created by cocalc-api tests"
45+
- **Important**: Projects are NOT automatically deleted after tests (no delete API available)
46+
- You'll see a note after tests indicating which projects were created
47+
- These test projects can be manually deleted from the CoCalc interface if desired
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Tests package for cocalc-api
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Pytest configuration and fixtures for cocalc-api tests.
3+
"""
4+
import os
5+
import pytest
6+
7+
from cocalc_api import Hub, Project
8+
9+
10+
@pytest.fixture(scope="session")
11+
def api_key():
12+
"""Get API key from environment variable."""
13+
key = os.environ.get("COCALC_API_KEY")
14+
if not key:
15+
pytest.fail("COCALC_API_KEY environment variable is required but not set")
16+
return key
17+
18+
19+
@pytest.fixture(scope="session")
20+
def cocalc_host():
21+
"""Get CoCalc host from environment variable, default to localhost:5000."""
22+
return os.environ.get("COCALC_HOST", "http://localhost:5000")
23+
24+
25+
@pytest.fixture(scope="session")
26+
def hub(api_key, cocalc_host):
27+
"""Create Hub client instance."""
28+
return Hub(api_key=api_key, host=cocalc_host)
29+
30+
31+
@pytest.fixture(scope="session")
32+
def temporary_project(hub):
33+
"""
34+
Create a temporary project for testing and return project info.
35+
36+
Note: Since there's no project deletion API available, the project
37+
will remain after tests. It can be manually deleted if needed.
38+
"""
39+
import time
40+
41+
# Create a project with a timestamp to make it unique and identifiable
42+
timestamp = time.strftime("%Y%m%d-%H%M%S")
43+
title = f"CoCalc API Test {timestamp}"
44+
description = "Temporary project created by cocalc-api tests"
45+
46+
project_id = hub.projects.create_project(title=title, description=description)
47+
48+
# Start the project so it can respond to API calls
49+
try:
50+
hub.projects.start(project_id)
51+
print(f"Started project {project_id}, waiting for it to become ready...")
52+
53+
# Wait for project to be ready (can take 10-15 seconds)
54+
import time
55+
from cocalc_api import Project
56+
57+
for attempt in range(10):
58+
time.sleep(5) # Wait 5 seconds before checking
59+
try:
60+
# Try to ping the project to see if it's ready
61+
test_project = Project(project_id=project_id, api_key=hub.api_key, host=hub.host)
62+
test_project.system.ping() # If this succeeds, project is ready
63+
print(f"✓ Project {project_id} is ready after {(attempt + 1) * 5} seconds")
64+
break
65+
except Exception:
66+
if attempt == 9: # Last attempt
67+
print(f"Warning: Project {project_id} did not become ready within 50 seconds")
68+
69+
except Exception as e:
70+
print(f"Warning: Failed to start project {project_id}: {e}")
71+
72+
project_info = {'project_id': project_id, 'title': title, 'description': description}
73+
74+
yield project_info
75+
76+
# Cleanup: No direct delete API available
77+
# The project will remain but can be manually cleaned up
78+
print(f"\nNote: Test project '{title}' (ID: {project_id}) created but not deleted - no delete API available")
79+
80+
81+
@pytest.fixture(scope="session")
82+
def project_client(temporary_project, api_key, cocalc_host):
83+
"""Create Project client instance using temporary project."""
84+
return Project(project_id=temporary_project['project_id'], api_key=api_key, host=cocalc_host)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Tests for Hub client functionality.
3+
"""
4+
import pytest
5+
6+
from cocalc_api import Hub
7+
8+
9+
class TestHubSystem:
10+
"""Tests for Hub system operations."""
11+
12+
def test_ping(self, hub):
13+
"""Test basic ping connectivity."""
14+
result = hub.system.ping()
15+
assert result is not None
16+
# The ping response should contain some basic server info
17+
assert isinstance(result, dict)
18+
19+
def test_hub_initialization(self, api_key, cocalc_host):
20+
"""Test Hub client initialization."""
21+
hub = Hub(api_key=api_key, host=cocalc_host)
22+
assert hub.api_key == api_key
23+
assert hub.host == cocalc_host
24+
assert hub.client is not None
25+
26+
def test_invalid_api_key(self, cocalc_host):
27+
"""Test behavior with invalid API key."""
28+
hub = Hub(api_key="invalid_key", host=cocalc_host)
29+
with pytest.raises((ValueError, RuntimeError, Exception)): # Should raise authentication error
30+
hub.system.ping()
31+
32+
def test_ping_timeout(self, api_key, cocalc_host):
33+
"""Test ping with timeout parameter."""
34+
hub = Hub(api_key=api_key, host=cocalc_host)
35+
result = hub.system.ping()
36+
assert result is not None
37+
38+
39+
class TestHubProjects:
40+
"""Tests for Hub project operations."""
41+
42+
def test_create_project(self, hub):
43+
"""Test creating a project via hub.projects.create_project."""
44+
import time
45+
timestamp = int(time.time())
46+
title = f"test-project-{timestamp}"
47+
description = "Test project for API testing"
48+
49+
project_id = hub.projects.create_project(title=title, description=description)
50+
51+
assert project_id is not None
52+
assert isinstance(project_id, str)
53+
assert len(project_id) > 0
54+
# Should be a UUID-like string
55+
assert '-' in project_id
56+
57+
def test_list_projects(self, hub):
58+
"""Test listing projects."""
59+
projects = hub.projects.get()
60+
assert isinstance(projects, list)
61+
# Each project should have basic fields
62+
for project in projects:
63+
assert 'project_id' in project
64+
assert isinstance(project['project_id'], str)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
Tests for Project client functionality.
3+
"""
4+
import pytest
5+
6+
from cocalc_api import Project
7+
8+
9+
class TestProjectCreation:
10+
"""Tests for project creation and management."""
11+
12+
def test_create_temporary_project(self, temporary_project):
13+
"""Test that a temporary project is created successfully."""
14+
assert temporary_project is not None
15+
assert 'project_id' in temporary_project
16+
assert 'title' in temporary_project
17+
assert 'description' in temporary_project
18+
assert temporary_project['title'].startswith('CoCalc API Test ')
19+
assert temporary_project['description'] == "Temporary project created by cocalc-api tests"
20+
# Project ID should be a UUID-like string
21+
assert len(temporary_project['project_id']) > 0
22+
assert '-' in temporary_project['project_id']
23+
24+
def test_project_exists_in_list(self, hub, temporary_project):
25+
"""Test that the created project appears in the projects list."""
26+
projects = hub.projects.get(all=True)
27+
project_ids = [p['project_id'] for p in projects]
28+
assert temporary_project['project_id'] in project_ids
29+
30+
31+
class TestProjectSystem:
32+
"""Tests for Project system operations."""
33+
34+
def test_ping(self, project_client):
35+
"""Test basic ping connectivity to project."""
36+
result = project_client.system.ping()
37+
assert result is not None
38+
assert isinstance(result, dict)
39+
40+
def test_project_initialization(self, api_key, cocalc_host):
41+
"""Test Project client initialization."""
42+
project_id = "test-project-id"
43+
project = Project(project_id=project_id, api_key=api_key, host=cocalc_host)
44+
assert project.project_id == project_id
45+
assert project.api_key == api_key
46+
assert project.host == cocalc_host
47+
assert project.client is not None
48+
49+
def test_project_with_temporary_project(self, project_client, temporary_project):
50+
"""Test Project client using the temporary project."""
51+
assert project_client.project_id == temporary_project['project_id']
52+
# Test that we can ping the specific project
53+
result = project_client.system.ping()
54+
assert result is not None
55+
assert isinstance(result, dict)
56+
57+
def test_exec_command(self, project_client):
58+
"""Test executing shell commands in the project."""
59+
# Test running 'date -Is' to get ISO date with seconds
60+
result = project_client.system.exec(command="date", args=["-Is"])
61+
62+
# Check the result structure
63+
assert 'stdout' in result
64+
assert 'stderr' in result
65+
assert 'exit_code' in result
66+
67+
# Should succeed
68+
assert result['exit_code'] == 0
69+
70+
# Should have minimal stderr
71+
assert result['stderr'] == '' or len(result['stderr']) == 0
72+
73+
# Parse the returned date and compare with current time
74+
from datetime import datetime
75+
import re
76+
77+
date_output = result['stdout'].strip()
78+
# Expected format: 2025-09-29T12:34:56+00:00 or similar
79+
80+
# Check if the output matches ISO format
81+
iso_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$'
82+
assert re.match(iso_pattern, date_output), f"Date output '{date_output}' doesn't match ISO format"
83+
84+
# Parse the date from the command output
85+
# Remove the timezone for comparison (date -Is includes timezone)
86+
date_part = date_output[:19] # Take YYYY-MM-DDTHH:MM:SS part
87+
remote_time = datetime.fromisoformat(date_part)
88+
89+
# Get current time
90+
current_time = datetime.now()
91+
92+
# Check if the times are close (within 60 seconds)
93+
time_diff = abs((current_time - remote_time).total_seconds())
94+
assert time_diff < 60, f"Time difference too large: {time_diff} seconds. Remote: {date_output}, Local: {current_time.isoformat()}"
95+
96+
def test_exec_stderr_and_exit_code(self, project_client):
97+
"""Test executing a command that writes to stderr and returns a specific exit code."""
98+
# Use bash to echo to stderr and exit with code 42
99+
bash_script = "echo 'test error message' >&2; exit 42"
100+
101+
# The API raises an exception for non-zero exit codes
102+
# but includes the stderr and exit code information in the error message
103+
with pytest.raises(RuntimeError) as exc_info:
104+
project_client.system.exec(command=bash_script, bash=True)
105+
106+
error_message = str(exc_info.value)
107+
108+
# Verify the error message contains expected information
109+
assert "exited with nonzero code 42" in error_message
110+
assert "stderr='test error message" in error_message
111+
112+
# Extract and verify the stderr content is properly captured
113+
import re
114+
stderr_match = re.search(r"stderr='([^']*)'", error_message)
115+
assert stderr_match is not None, "Could not find stderr in error message"
116+
stderr_content = stderr_match.group(1).strip()
117+
assert stderr_content == "test error message"

0 commit comments

Comments
 (0)