Skip to content

Commit dc4396c

Browse files
committed
cocalc-api/test: fix edgecases in api and improve testing
1 parent 10646ee commit dc4396c

File tree

23 files changed

+837
-97
lines changed

23 files changed

+837
-97
lines changed

.github/workflows/make-and-test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ jobs:
278278
run: |
279279
export COCALC_API_KEY=$(cat src/api_key.txt)
280280
export COCALC_HOST=http://localhost:5000
281+
export CI=true
281282
cd src/python/cocalc-api && make ci
282283
env:
283284
PGDATABASE: smc

src/packages/conat/hub/api/projects.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { authFirstRequireAccount } from "./util";
22
import { type CreateProjectOptions } from "@cocalc/util/db-schema/projects";
33
import { type UserCopyOptions } from "@cocalc/util/db-schema/projects";
4+
import {
5+
type ProjectState,
6+
type ProjectStatus,
7+
} from "@cocalc/util/db-schema/projects";
48

59
export const projects = {
610
createProject: authFirstRequireAccount,
@@ -13,6 +17,9 @@ export const projects = {
1317
start: authFirstRequireAccount,
1418
stop: authFirstRequireAccount,
1519
deleteProject: authFirstRequireAccount,
20+
touch: authFirstRequireAccount,
21+
state: authFirstRequireAccount,
22+
status: authFirstRequireAccount,
1623
};
1724

1825
export type AddCollaborator =
@@ -104,5 +111,18 @@ export interface Projects {
104111

105112
start: (opts: { account_id: string; project_id: string }) => Promise<void>;
106113
stop: (opts: { account_id: string; project_id: string }) => Promise<void>;
107-
deleteProject: (opts: { account_id: string; project_id: string }) => Promise<void>;
114+
deleteProject: (opts: {
115+
account_id: string;
116+
project_id: string;
117+
}) => Promise<void>;
118+
119+
touch: (opts: { account_id: string; project_id: string }) => Promise<void>;
120+
state: (opts: {
121+
account_id: string;
122+
project_id: string;
123+
}) => Promise<ProjectState>;
124+
status: (opts: {
125+
account_id: string;
126+
project_id: string;
127+
}) => Promise<ProjectStatus>;
108128
}

src/packages/server/accounts/get-name.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ type Names = {
3737
function canonicalName(row) {
3838
// some accounts have these null for some reason sometimes, but it is nice if client code can assume not null.
3939
let { first_name = "", last_name = "", profile } = row;
40-
first_name = first_name.trim();
41-
last_name = last_name.trim();
40+
first_name = (first_name ?? "").trim();
41+
last_name = (last_name ?? "").trim();
4242
if (!first_name && !last_name) {
4343
// Also ensure both are not empty so you can always see something. I think the frontend and/or api doesn't
4444
// allow a user to make their name empty, but *just in case* we do this.

src/packages/server/conat/api/projects.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,45 @@ export async function stop({
106106

107107
import deleteProject from "@cocalc/server/projects/delete";
108108
export { deleteProject };
109+
110+
export async function touch({
111+
account_id,
112+
project_id,
113+
}: {
114+
account_id: string;
115+
project_id: string;
116+
}): Promise<void> {
117+
if (!(await isCollaborator({ account_id, project_id }))) {
118+
throw Error("must be collaborator on project to touch it");
119+
}
120+
const project = await getProject(project_id);
121+
await project.touch(account_id);
122+
}
123+
124+
export async function state({
125+
account_id,
126+
project_id,
127+
}: {
128+
account_id: string;
129+
project_id: string;
130+
}): Promise<any> {
131+
if (!(await isCollaborator({ account_id, project_id }))) {
132+
throw Error("must be collaborator on project to get state");
133+
}
134+
const project = await getProject(project_id);
135+
return await project.state();
136+
}
137+
138+
export async function status({
139+
account_id,
140+
project_id,
141+
}: {
142+
account_id: string;
143+
project_id: string;
144+
}): Promise<any> {
145+
if (!(await isCollaborator({ account_id, project_id }))) {
146+
throw Error("must be collaborator on project to get status");
147+
}
148+
const project = await getProject(project_id);
149+
return await project.status();
150+
}

src/python/cocalc-api/Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ test:
3737
test-verbose:
3838
uv run pytest -v
3939

40-
ci:
41-
uv run pytest --junitxml=test-results.xml --cov=src --cov-report=term-missing --cov-report=html
40+
ci-create-project:
41+
@uv run python3 scripts/ci-create-project.py
42+
43+
ci: ci-create-project
44+
export COCALC_PROJECT_ID=$$($(MAKE) ci-create-project) && uv run pytest --junitxml=test-results.xml --cov=src --cov-report=term-missing --cov-report=html
4245

4346
coverage:
4447
uv run pytest --cov=src --cov-report=term-missing --cov-report=html

src/python/cocalc-api/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ omit = [
5151
"*/__pycache__/*",
5252
"*/venv/*",
5353
"*/env/*",
54+
"*/mcp/*",
5455
]
5556

5657
[tool.coverage.report]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Create a test project for CI tests using the cocalc-api Hub.
4+
5+
This script creates a temporary project for running tests that require a project ID
6+
(e.g., cocalc-api tests with account-scoped API keys).
7+
8+
Outputs the project ID to stdout so it can be captured by the CI workflow.
9+
"""
10+
11+
import os
12+
import sys
13+
import httpx
14+
15+
16+
def main():
17+
"""Create a test project and output its ID."""
18+
# Get configuration from environment
19+
api_key = os.environ.get("COCALC_API_KEY")
20+
host = os.environ.get("COCALC_HOST", "http://localhost:5000")
21+
22+
if not api_key:
23+
print("Error: COCALC_API_KEY environment variable not set", file=sys.stderr)
24+
sys.exit(1)
25+
26+
try:
27+
# Create HTTP client with API key authentication
28+
client = httpx.Client(
29+
auth=(api_key, ""),
30+
headers={"Content-Type": "application/json"},
31+
timeout=120.0
32+
)
33+
34+
# Call the hub API to create a project
35+
resp = client.post(
36+
f"{host}/api/conat/hub",
37+
json={
38+
"name": "projects.createProject",
39+
"args": [{"title": "CI Test Project"}]
40+
}
41+
)
42+
43+
result = resp.json()
44+
45+
# Check for errors in the response
46+
if "error" in result:
47+
print(f"Error creating project: {result['error']}", file=sys.stderr)
48+
sys.exit(1)
49+
50+
# The result should be the project ID
51+
project_id = result
52+
if not project_id or project_id == "None":
53+
print(f"Error: Invalid project ID returned: {result}", file=sys.stderr)
54+
sys.exit(1)
55+
56+
# Output the project ID to stdout
57+
print(project_id)
58+
59+
except Exception as e:
60+
print(f"Error: {e}", file=sys.stderr)
61+
sys.exit(1)
62+
63+
64+
if __name__ == "__main__":
65+
main()

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

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def user_search(self, query: str) -> UserSearchResult:
147147
'created': 1756056224470,
148148
'email_address_verified': None}]
149149
"""
150-
...
150+
... # pragma: no cover
151151

152152

153153
class Projects:
@@ -246,7 +246,7 @@ def add_collaborator(self, project_id: str | list[str], account_id: str | list[s
246246
Returns:
247247
dict[str, Any]: JSON response from the API.
248248
"""
249-
...
249+
... # pragma: no cover
250250

251251
@api_method("projects.removeCollaborator", opts=True)
252252
def remove_collaborator(self, project_id: str, account_id: str) -> dict[str, Any]:
@@ -260,7 +260,7 @@ def remove_collaborator(self, project_id: str, account_id: str) -> dict[str, Any
260260
Returns:
261261
dict[str, Any]: JSON response from the API.
262262
"""
263-
...
263+
... # pragma: no cover
264264

265265
@api_method("projects.start")
266266
def start(self, project_id: str) -> dict[str, Any]:
@@ -295,6 +295,55 @@ def delete(self, project_id: str) -> dict[str, Any]:
295295
"""
296296
...
297297

298+
@api_method("projects.touch")
299+
def touch(self, project_id: str) -> dict[str, Any]:
300+
"""
301+
Signal that the project is in use by updating its last_edited timestamp.
302+
This also ensures the project is started.
303+
304+
Args:
305+
project_id (str): Project ID of the project to touch.
306+
307+
Returns:
308+
dict[str, Any]: API response indicating success.
309+
"""
310+
...
311+
312+
@api_method("projects.state")
313+
def state(self, project_id: str) -> dict[str, Any]:
314+
"""
315+
Get the current state of a project (running, stopped, starting, etc.).
316+
317+
Args:
318+
project_id (str): Project ID of the project.
319+
320+
Returns:
321+
dict[str, Any]: Project state object containing:
322+
- state: "running" | "stopped" | "starting" | "restarting" | "error"
323+
- ip: IP address where project is running (if running)
324+
- error: Error message (if in error state)
325+
- time: Timestamp of last state change
326+
"""
327+
...
328+
329+
@api_method("projects.status")
330+
def status(self, project_id: str) -> dict[str, Any]:
331+
"""
332+
Get detailed status information about a project.
333+
334+
Args:
335+
project_id (str): Project ID of the project.
336+
337+
Returns:
338+
dict[str, Any]: Project status object containing:
339+
- project.pid: PID of project server process
340+
- start_ts: Timestamp when project started
341+
- version: Project code version
342+
- disk_MB: Disk usage in MB
343+
- memory: Memory usage information
344+
"""
345+
...
346+
298347

299348
class Jupyter:
300349

@@ -324,7 +373,7 @@ def kernels(self, project_id: Optional[str] = None) -> list[dict[str, Any]]:
324373
>>> 'python3' in kernel_names
325374
True
326375
"""
327-
...
376+
... # pragma: no cover
328377

329378
@api_method("jupyter.execute", timeout_seconds=True)
330379
def execute(
@@ -365,7 +414,7 @@ def execute(
365414
... project_id='6e75dbf1-0342-4249-9dce-6b21648656e9')
366415
{'output': [{'data': {'text/plain': '3^4 * 5^2'}}], ...}
367416
"""
368-
...
417+
... # pragma: no cover
369418

370419

371420
class Sync:
@@ -385,7 +434,7 @@ def history(self, project_id: str, path: str) -> list[dict[str, Any]]: # type:
385434
Returns:
386435
list[dict[str, Any]]: Array of patches in a compressed diff-match-patch format, along with time and user data.
387436
"""
388-
...
437+
... # pragma: no cover
389438

390439

391440
class Database:
@@ -420,7 +469,7 @@ def query(self, query: dict[str, Any]) -> dict[str, Any]:
420469
>>> hub.db.query({"accounts":{"first_name":None}})
421470
{'accounts': {'first_name': 'W'}}
422471
"""
423-
...
472+
... # pragma: no cover
424473

425474

426475
class Messages:
@@ -442,7 +491,7 @@ def send(self, subject: str, body: str, to_ids: list[str], reply_id: Optional[in
442491
Returns:
443492
int: ID of the message.
444493
"""
445-
...
494+
... # pragma: no cover
446495

447496
@api_method("messages.get")
448497
def get(
@@ -462,7 +511,7 @@ def get(
462511
Returns:
463512
list[MessageType]: List of messages.
464513
"""
465-
...
514+
... # pragma: no cover
466515

467516

468517
"""

src/python/cocalc-api/src/cocalc_api/mcp/mcp_debug.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ async def debug_mcp_server() -> None:
4343
read_stream, write_stream = client
4444
async with ClientSession(read_stream, write_stream) as session:
4545
# Initialize the connection
46-
print("\n=== MCP Server Debug Information ===\n",
47-
file=sys.stderr)
46+
print("\n=== MCP Server Debug Information ===\n", file=sys.stderr)
4847

4948
# Get capabilities
5049
info = await session.initialize()
@@ -105,16 +104,13 @@ async def debug_mcp_server() -> None:
105104
except Exception as e:
106105
error_str = str(e)
107106
if "COCALC_API_KEY" in error_str or "not set" in error_str:
108-
print("Error: COCALC_API_KEY environment variable is not set",
109-
file=sys.stderr)
107+
print("Error: COCALC_API_KEY environment variable is not set", file=sys.stderr)
110108
print("\nUsage:", file=sys.stderr)
111109
print(" export COCALC_API_KEY='sk-...'", file=sys.stderr)
112110
print(" make mcp-debug", file=sys.stderr)
113111
elif "project_id" in error_str.lower():
114-
print("Error: Project-scoped API key requires COCALC_PROJECT_ID",
115-
file=sys.stderr)
116-
print("\nFor project-scoped API keys, provide the project ID:",
117-
file=sys.stderr)
112+
print("Error: Project-scoped API key requires COCALC_PROJECT_ID", file=sys.stderr)
113+
print("\nFor project-scoped API keys, provide the project ID:", file=sys.stderr)
118114
print(" export COCALC_API_KEY='sk-...'", file=sys.stderr)
119115
print(" export COCALC_PROJECT_ID='uuid-...'", file=sys.stderr)
120116
print(" make mcp-debug", file=sys.stderr)

0 commit comments

Comments
 (0)