Skip to content

Commit e8b0e5f

Browse files
hubert-marekclaude
andcommitted
Unified conformance tests across all 4 services with real API validation
- Box: added DELETE comments/tasks, file version upload, fixed scoring (collections ignore, dev_token bug). 105/106 (99%) - Calendar: added pytest entry point, fixed auth (seed email mismatch), added optional fields. 77/77 (100%) - Linear: added pytest entry point, resource queries, mutation parity, removed invalid tests. 89/90 (98%) - Slack: added 9 new docs-golden shape tests, validation wrapper. 22/22 (100%) - Registered pytest conformance/external/replica_only markers - Updated rebuttal with real conformance numbers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 334043d commit e8b0e5f

7 files changed

Lines changed: 649 additions & 23 deletions

backend/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ dependencies = [
2727

2828
[tool.pytest.ini_options]
2929
addopts = ["--tb=short"]
30+
markers = [
31+
"conformance: API conformance/parity tests against production APIs",
32+
"external: requires live API credentials (tokens/keys)",
33+
"replica_only: tests against replica only (no external credentials needed)",
34+
]

backend/tests/integration/test_slack_api_docs.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,144 @@ async def test_search_messages_doc_shape(self, slack_client: AsyncClient) -> Non
357357
}
358358
assert expected_match_keys <= match.keys()
359359
assert HIGHLIGHT_START in match["text"] and HIGHLIGHT_END in match["text"]
360+
361+
async def test_auth_test_doc_shape(self, slack_client: AsyncClient) -> None:
362+
resp = await slack_client.post("/auth.test", json={})
363+
assert resp.status_code == 200
364+
data = resp.json()
365+
assert data["ok"] is True
366+
assert {"user_id", "user", "team_id", "team"} <= data.keys()
367+
assert data["user_id"] == USER_AGENT
368+
369+
async def test_chat_update_doc_shape(self, slack_client: AsyncClient) -> None:
370+
post_resp = await slack_client.post(
371+
"/chat.postMessage",
372+
json={"channel": CHANNEL_GENERAL, "text": "Original text for update"},
373+
)
374+
assert post_resp.status_code == 200
375+
ts = post_resp.json()["ts"]
376+
377+
resp = await slack_client.post(
378+
"/chat.update",
379+
json={"channel": CHANNEL_GENERAL, "ts": ts, "text": "Updated text"},
380+
)
381+
assert resp.status_code == 200
382+
data = resp.json()
383+
assert data["ok"] is True
384+
assert {"ok", "channel", "ts", "text"} <= data.keys()
385+
assert data["text"] == "Updated text"
386+
387+
async def test_conversations_archive_doc_shape(
388+
self, slack_client: AsyncClient
389+
) -> None:
390+
channel_name = _unique_name("doc-archive")
391+
create_resp = await slack_client.post(
392+
"/conversations.create", json={"name": channel_name, "is_private": False}
393+
)
394+
assert create_resp.status_code == 200
395+
channel_id = create_resp.json()["channel"]["id"]
396+
397+
resp = await slack_client.post(
398+
"/conversations.archive", json={"channel": channel_id}
399+
)
400+
assert resp.status_code == 200
401+
data = resp.json()
402+
assert data["ok"] is True
403+
404+
async def test_conversations_unarchive_doc_shape(
405+
self, slack_client: AsyncClient
406+
) -> None:
407+
channel_name = _unique_name("doc-unarch")
408+
create_resp = await slack_client.post(
409+
"/conversations.create", json={"name": channel_name, "is_private": False}
410+
)
411+
assert create_resp.status_code == 200
412+
channel_id = create_resp.json()["channel"]["id"]
413+
414+
await slack_client.post(
415+
"/conversations.archive", json={"channel": channel_id}
416+
)
417+
418+
resp = await slack_client.post(
419+
"/conversations.unarchive", json={"channel": channel_id}
420+
)
421+
assert resp.status_code == 200
422+
data = resp.json()
423+
assert data["ok"] is True
424+
425+
async def test_conversations_rename_doc_shape(
426+
self, slack_client: AsyncClient
427+
) -> None:
428+
channel_name = _unique_name("doc-rename")
429+
create_resp = await slack_client.post(
430+
"/conversations.create", json={"name": channel_name, "is_private": False}
431+
)
432+
assert create_resp.status_code == 200
433+
channel_id = create_resp.json()["channel"]["id"]
434+
435+
new_name = _unique_name("doc-renamed")
436+
resp = await slack_client.post(
437+
"/conversations.rename",
438+
json={"channel": channel_id, "name": new_name},
439+
)
440+
assert resp.status_code == 200
441+
data = resp.json()
442+
assert data["ok"] is True
443+
assert data["channel"]["name"] == new_name
444+
445+
async def test_conversations_kick_doc_shape(
446+
self, slack_client: AsyncClient, slack_client_john: AsyncClient
447+
) -> None:
448+
channel_name = _unique_name("doc-kick")
449+
create_resp = await slack_client.post(
450+
"/conversations.create", json={"name": channel_name, "is_private": False}
451+
)
452+
assert create_resp.status_code == 200
453+
channel_id = create_resp.json()["channel"]["id"]
454+
455+
await slack_client.post(
456+
"/conversations.invite",
457+
json={"channel": channel_id, "users": USER_JOHN},
458+
)
459+
460+
resp = await slack_client.post(
461+
"/conversations.kick",
462+
json={"channel": channel_id, "user": USER_JOHN},
463+
)
464+
assert resp.status_code == 200
465+
data = resp.json()
466+
assert data["ok"] is True
467+
468+
async def test_conversations_members_doc_shape(
469+
self, slack_client: AsyncClient
470+
) -> None:
471+
resp = await slack_client.get(
472+
f"/conversations.members?channel={CHANNEL_GENERAL}&limit=10"
473+
)
474+
assert resp.status_code == 200
475+
data = resp.json()
476+
assert data["ok"] is True
477+
assert "members" in data
478+
assert isinstance(data["members"], list)
479+
assert "response_metadata" in data
480+
481+
async def test_users_list_doc_shape(self, slack_client: AsyncClient) -> None:
482+
resp = await slack_client.get("/users.list?limit=5")
483+
assert resp.status_code == 200
484+
data = resp.json()
485+
assert data["ok"] is True
486+
assert "members" in data
487+
assert isinstance(data["members"], list)
488+
if data["members"]:
489+
user = data["members"][0]
490+
assert {"id", "name", "profile"} <= user.keys()
491+
492+
async def test_users_conversations_doc_shape(
493+
self, slack_client: AsyncClient
494+
) -> None:
495+
resp = await slack_client.get(f"/users.conversations?user={USER_AGENT}&limit=5")
496+
assert resp.status_code == 200
497+
data = resp.json()
498+
assert data["ok"] is True
499+
assert "channels" in data
500+
assert isinstance(data["channels"], list)

backend/tests/validation/test_box_parity.py

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import os
1919
import sys
2020
import time
21+
22+
import pytest
2123
from datetime import datetime, timezone
2224
from typing import Any, Callable, Dict, List, Optional
2325

@@ -96,6 +98,8 @@
9698
"metadata",
9799
# Comment fields
98100
"tagged_message",
101+
# Collections (only populated on Business/Enterprise tier accounts)
102+
"collections",
99103
}
100104

101105

@@ -1899,7 +1903,7 @@ def run_file_tests(self) -> tuple[int, int]:
18991903
files={
19001904
"file": (version_filename, io.BytesIO(v2_content)),
19011905
},
1902-
headers={"Authorization": f"Bearer {self.dev_token}"},
1906+
headers={"Authorization": f"Bearer {self.prod_headers['Authorization'].split()[-1]}"},
19031907
)
19041908

19051909
replica_version_resp = requests.post(
@@ -1978,7 +1982,7 @@ def run_file_tests(self) -> tuple[int, int]:
19781982
f"https://upload.box.com/api/2.0/files/{prod_file_id}/content",
19791983
files={"file": (ifmatch_filename, io.BytesIO(content_v2))},
19801984
headers={
1981-
"Authorization": f"Bearer {self.dev_token}",
1985+
"Authorization": f"Bearer {self.prod_headers['Authorization'].split()[-1]}",
19821986
"If-Match": prod_etag,
19831987
},
19841988
)
@@ -2114,6 +2118,53 @@ def run_file_tests(self) -> tuple[int, int]:
21142118
except Exception as e:
21152119
print(f" {e}")
21162120

2121+
# === POST /files/{id}/content (file version upload) ===
2122+
2123+
# [COMMON] Upload a new version of an existing file
2124+
total += 1
2125+
print(" POST /files/{id}/content (new version)...", end=" ")
2126+
try:
2127+
ts = datetime.now(timezone.utc).strftime("%H%M%S%f")
2128+
version_content = f"Updated content v2 at {ts}".encode("utf-8")
2129+
2130+
prod_resp = requests.post(
2131+
f"https://upload.box.com/api/2.0/files/{self.prod_file_id}/content",
2132+
files={
2133+
"file": (f"version_test_{ts}.txt", io.BytesIO(version_content), "application/octet-stream"),
2134+
},
2135+
headers={"Authorization": self.prod_headers["Authorization"]},
2136+
)
2137+
replica_resp = requests.post(
2138+
f"{self.replica_url}/files/{self.replica_file_id}/content",
2139+
files={
2140+
"file": (f"version_test_{ts}.txt", io.BytesIO(version_content), "application/octet-stream"),
2141+
},
2142+
)
2143+
2144+
prod_ok = prod_resp.status_code in (200, 201)
2145+
replica_ok = replica_resp.status_code in (200, 201)
2146+
2147+
if prod_ok and replica_ok:
2148+
prod_shape = self.extract_shape(prod_resp.json())
2149+
replica_shape = self.extract_shape(replica_resp.json())
2150+
diffs = self.compare_shapes(prod_shape, replica_shape, "data")
2151+
if diffs:
2152+
print(" SCHEMA MISMATCH")
2153+
for d in diffs[:2]:
2154+
print(f" {d}")
2155+
else:
2156+
print("✅")
2157+
passed += 1
2158+
elif prod_ok == replica_ok:
2159+
print("✅ (both failed)")
2160+
passed += 1
2161+
else:
2162+
print(
2163+
f" STATUS: prod={prod_resp.status_code}, replica={replica_resp.status_code}"
2164+
)
2165+
except Exception as e:
2166+
print(f" {e}")
2167+
21172168
return passed, total
21182169

21192170
def run_comment_tests(self) -> tuple[int, int]:
@@ -2188,9 +2239,8 @@ def run_comment_tests(self) -> tuple[int, int]:
21882239
else:
21892240
print("✅")
21902241
passed += 1
2191-
# IDs available for potential follow-up tests:
2192-
# prod_comment_id = prod_data.get("id")
2193-
# replica_comment_id = replica_data.get("id")
2242+
prod_comment_id = prod_data.get("id")
2243+
replica_comment_id = replica_data.get("id")
21942244
else:
21952245
print(
21962246
f" STATUS: prod={prod_resp.status_code}, replica={replica_resp.status_code}"
@@ -2370,6 +2420,38 @@ def run_comment_tests(self) -> tuple[int, int]:
23702420
):
23712421
passed += 1
23722422

2423+
# === DELETE /comments/{id} ===
2424+
2425+
# [COMMON] Delete a comment
2426+
if prod_comment_id and replica_comment_id:
2427+
total += 1
2428+
print(" DELETE /comments/{id}...", end=" ")
2429+
try:
2430+
prod_resp = self.api_prod("DELETE", f"comments/{prod_comment_id}")
2431+
replica_resp = self.api_replica(
2432+
"DELETE", f"comments/{replica_comment_id}"
2433+
)
2434+
prod_ok = prod_resp.status_code in (200, 204)
2435+
replica_ok = replica_resp.status_code in (200, 204)
2436+
if prod_ok == replica_ok:
2437+
print("✅")
2438+
passed += 1
2439+
else:
2440+
print(
2441+
f" STATUS: prod={prod_resp.status_code}, replica={replica_resp.status_code}"
2442+
)
2443+
except Exception as e:
2444+
print(f" {e}")
2445+
2446+
# [EDGE] Delete non-existent comment (404)
2447+
total += 1
2448+
if self.test_operation(
2449+
"DELETE /comments/{id} (non-existent - 404)",
2450+
lambda: self.api_prod("DELETE", "comments/999999999999999"),
2451+
lambda: self.api_replica("DELETE", "comments/999999999999999"),
2452+
):
2453+
passed += 1
2454+
23732455
return passed, total
23742456

23752457
def run_task_tests(self) -> tuple[int, int]:
@@ -2396,6 +2478,8 @@ def run_task_tests(self) -> tuple[int, int]:
23962478
print("\n✅ Task Operations:")
23972479
passed = 0
23982480
total = 0
2481+
prod_task_id = None
2482+
replica_task_id = None
23992483

24002484
if self.prod_file_id and self.replica_file_id:
24012485
# === POST /tasks ===
@@ -2445,6 +2529,8 @@ def run_task_tests(self) -> tuple[int, int]:
24452529
else:
24462530
print("✅")
24472531
passed += 1
2532+
prod_task_id = prod_data.get("id")
2533+
replica_task_id = replica_data.get("id")
24482534
else:
24492535
print(
24502536
f" STATUS: prod={prod_resp.status_code}, replica={replica_resp.status_code}"
@@ -2656,6 +2742,38 @@ def run_task_tests(self) -> tuple[int, int]:
26562742
):
26572743
passed += 1
26582744

2745+
# === DELETE /tasks/{id} ===
2746+
2747+
# [COMMON] Delete a task
2748+
if prod_task_id and replica_task_id:
2749+
total += 1
2750+
print(" DELETE /tasks/{id}...", end=" ")
2751+
try:
2752+
prod_resp = self.api_prod("DELETE", f"tasks/{prod_task_id}")
2753+
replica_resp = self.api_replica(
2754+
"DELETE", f"tasks/{replica_task_id}"
2755+
)
2756+
prod_ok = prod_resp.status_code in (200, 204)
2757+
replica_ok = replica_resp.status_code in (200, 204)
2758+
if prod_ok == replica_ok:
2759+
print("✅")
2760+
passed += 1
2761+
else:
2762+
print(
2763+
f" STATUS: prod={prod_resp.status_code}, replica={replica_resp.status_code}"
2764+
)
2765+
except Exception as e:
2766+
print(f" {e}")
2767+
2768+
# [EDGE] Delete non-existent task (404)
2769+
total += 1
2770+
if self.test_operation(
2771+
"DELETE /tasks/{id} (non-existent - 404)",
2772+
lambda: self.api_prod("DELETE", "tasks/999999999999999"),
2773+
lambda: self.api_replica("DELETE", "tasks/999999999999999"),
2774+
):
2775+
passed += 1
2776+
26592777
return passed, total
26602778

26612779
def run_hub_tests(self) -> tuple[int, int]:
@@ -4016,13 +4134,12 @@ def run_tests(self):
40164134
# =============================================================================
40174135

40184136

4137+
@pytest.mark.conformance
4138+
@pytest.mark.external
40194139
def test_box_parity():
40204140
"""Run Box parity tests as pytest test."""
40214141
if not BOX_DEV_TOKEN:
4022-
print("ERROR: BOX_DEV_TOKEN environment variable not set")
4023-
print("Set it via: export BOX_DEV_TOKEN=<your_token>")
4024-
print("Or edit the BOX_DEV_TOKEN constant in this file")
4025-
return
4142+
pytest.skip("BOX_DEV_TOKEN environment variable not set")
40264143

40274144
tester = BoxParityTester(BOX_DEV_TOKEN)
40284145
passed, total = tester.run_tests()

0 commit comments

Comments
 (0)