test_external_api_integration.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. """
  2. Integration tests for External API.
  3. Tests the complete flow: project init → config → dispatch → annotate → export.
  4. """
  5. import pytest
  6. from fastapi.testclient import TestClient
  7. from datetime import datetime
  8. import json
  9. import os
  10. import sys
  11. # Add backend to path
  12. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  13. from main import app
  14. from database import get_db_connection
  15. @pytest.fixture(scope="module")
  16. def client():
  17. """Create test client."""
  18. return TestClient(app)
  19. @pytest.fixture(scope="module")
  20. def admin_token(client):
  21. """Get admin authentication token."""
  22. # Try to login as admin
  23. response = client.post("/api/auth/login", json={
  24. "username": "admin",
  25. "password": "admin123"
  26. })
  27. if response.status_code == 200:
  28. return response.json()["access_token"]
  29. # If admin doesn't exist, create one
  30. response = client.post("/api/auth/register", json={
  31. "username": "admin",
  32. "email": "admin@test.com",
  33. "password": "admin123",
  34. "role": "admin"
  35. })
  36. if response.status_code in [200, 201]:
  37. return response.json()["access_token"]
  38. # Try login again
  39. response = client.post("/api/auth/login", json={
  40. "username": "admin",
  41. "password": "admin123"
  42. })
  43. return response.json()["access_token"]
  44. @pytest.fixture(scope="module")
  45. def annotator_token(client):
  46. """Get annotator authentication token."""
  47. # Try to login as annotator
  48. response = client.post("/api/auth/login", json={
  49. "username": "annotator1",
  50. "password": "annotator123"
  51. })
  52. if response.status_code == 200:
  53. return response.json()["access_token"]
  54. # If annotator doesn't exist, create one
  55. response = client.post("/api/auth/register", json={
  56. "username": "annotator1",
  57. "email": "annotator1@test.com",
  58. "password": "annotator123",
  59. "role": "annotator"
  60. })
  61. if response.status_code in [200, 201]:
  62. return response.json()["access_token"]
  63. # Try login again
  64. response = client.post("/api/auth/login", json={
  65. "username": "annotator1",
  66. "password": "annotator123"
  67. })
  68. return response.json()["access_token"]
  69. class TestExternalAPIAuthentication:
  70. """Test external API authentication."""
  71. def test_init_project_without_token(self, client):
  72. """Test that init project requires authentication."""
  73. response = client.post("/api/external/projects/init", json={
  74. "name": "Test Project",
  75. "task_type": "image_classification",
  76. "data": [{"content": "http://example.com/1.jpg"}]
  77. })
  78. assert response.status_code == 401
  79. def test_init_project_with_invalid_token(self, client):
  80. """Test that init project rejects invalid token."""
  81. response = client.post(
  82. "/api/external/projects/init",
  83. json={
  84. "name": "Test Project",
  85. "task_type": "image_classification",
  86. "data": [{"content": "http://example.com/1.jpg"}]
  87. },
  88. headers={"Authorization": "Bearer invalid_token"}
  89. )
  90. assert response.status_code == 401
  91. def test_init_project_with_annotator_token(self, client, annotator_token):
  92. """Test that init project requires admin role."""
  93. response = client.post(
  94. "/api/external/projects/init",
  95. json={
  96. "name": "Test Project",
  97. "task_type": "image_classification",
  98. "data": [{"content": "http://example.com/1.jpg"}]
  99. },
  100. headers={"Authorization": f"Bearer {annotator_token}"}
  101. )
  102. assert response.status_code == 403
  103. class TestExternalAPIProjectInit:
  104. """Test external API project initialization."""
  105. def test_init_project_success(self, client, admin_token):
  106. """Test successful project initialization."""
  107. response = client.post(
  108. "/api/external/projects/init",
  109. json={
  110. "name": "External Test Project",
  111. "task_type": "image_classification",
  112. "data": [
  113. {"content": "http://example.com/1.jpg"},
  114. {"content": "http://example.com/2.jpg"},
  115. {"content": "http://example.com/3.jpg"}
  116. ],
  117. "external_id": "ext_123"
  118. },
  119. headers={"Authorization": f"Bearer {admin_token}"}
  120. )
  121. assert response.status_code == 201
  122. data = response.json()
  123. assert "project_id" in data
  124. assert data["task_count"] == 3
  125. assert data["status"] == "draft"
  126. def test_init_project_with_description(self, client, admin_token):
  127. """Test project initialization with description."""
  128. response = client.post(
  129. "/api/external/projects/init",
  130. json={
  131. "name": "Project with Description",
  132. "description": "This is a test project",
  133. "task_type": "text_classification",
  134. "data": [
  135. {"content": "Sample text 1"},
  136. {"content": "Sample text 2"}
  137. ]
  138. },
  139. headers={"Authorization": f"Bearer {admin_token}"}
  140. )
  141. assert response.status_code == 201
  142. data = response.json()
  143. assert data["task_count"] == 2
  144. def test_init_project_empty_data(self, client, admin_token):
  145. """Test project initialization with empty data items."""
  146. response = client.post(
  147. "/api/external/projects/init",
  148. json={
  149. "name": "Empty Project",
  150. "task_type": "image_classification",
  151. "data": []
  152. },
  153. headers={"Authorization": f"Bearer {admin_token}"}
  154. )
  155. # Should fail validation (min_length=1)
  156. assert response.status_code == 422
  157. class TestExternalAPIProgress:
  158. """Test external API progress query."""
  159. def test_get_progress_success(self, client, admin_token):
  160. """Test successful progress query."""
  161. # First create a project
  162. init_response = client.post(
  163. "/api/external/projects/init",
  164. json={
  165. "name": "Progress Test Project",
  166. "task_type": "image_classification",
  167. "data": [
  168. {"content": "http://example.com/1.jpg"},
  169. {"content": "http://example.com/2.jpg"}
  170. ]
  171. },
  172. headers={"Authorization": f"Bearer {admin_token}"}
  173. )
  174. assert init_response.status_code == 201
  175. project_id = init_response.json()["project_id"]
  176. # Get progress
  177. response = client.get(
  178. f"/api/external/projects/{project_id}/progress",
  179. headers={"Authorization": f"Bearer {admin_token}"}
  180. )
  181. assert response.status_code == 200
  182. data = response.json()
  183. assert data["project_id"] == project_id
  184. assert data["total_tasks"] == 2
  185. assert data["completed_tasks"] == 0
  186. assert data["completion_percentage"] == 0.0
  187. def test_get_progress_not_found(self, client, admin_token):
  188. """Test progress query for non-existent project."""
  189. response = client.get(
  190. "/api/external/projects/nonexistent_id/progress",
  191. headers={"Authorization": f"Bearer {admin_token}"}
  192. )
  193. assert response.status_code == 404
  194. class TestExternalAPIExport:
  195. """Test external API data export."""
  196. def test_export_json_format(self, client, admin_token):
  197. """Test JSON format export."""
  198. # First create a project
  199. init_response = client.post(
  200. "/api/external/projects/init",
  201. json={
  202. "name": "Export Test Project",
  203. "task_type": "image_classification",
  204. "data": [
  205. {"content": "http://example.com/1.jpg"}
  206. ]
  207. },
  208. headers={"Authorization": f"Bearer {admin_token}"}
  209. )
  210. assert init_response.status_code == 201
  211. project_id = init_response.json()["project_id"]
  212. # Export data
  213. response = client.post(
  214. f"/api/external/projects/{project_id}/export",
  215. json={
  216. "format": "json",
  217. "completed_only": False
  218. },
  219. headers={"Authorization": f"Bearer {admin_token}"}
  220. )
  221. assert response.status_code == 200
  222. data = response.json()
  223. assert "file_url" in data
  224. def test_export_invalid_format(self, client, admin_token):
  225. """Test export with invalid format."""
  226. # First create a project
  227. init_response = client.post(
  228. "/api/external/projects/init",
  229. json={
  230. "name": "Invalid Export Test",
  231. "task_type": "image_classification",
  232. "data": [{"content": "http://example.com/1.jpg"}]
  233. },
  234. headers={"Authorization": f"Bearer {admin_token}"}
  235. )
  236. assert init_response.status_code == 201
  237. project_id = init_response.json()["project_id"]
  238. # Try invalid format
  239. response = client.post(
  240. f"/api/external/projects/{project_id}/export",
  241. json={
  242. "format": "invalid_format",
  243. "completed_only": False
  244. },
  245. headers={"Authorization": f"Bearer {admin_token}"}
  246. )
  247. assert response.status_code == 422 # Validation error
  248. class TestProjectStatusTransitions:
  249. """Test project status transitions."""
  250. def test_valid_status_transition(self, client, admin_token):
  251. """Test valid status transition: draft → configuring."""
  252. # Create project
  253. init_response = client.post(
  254. "/api/external/projects/init",
  255. json={
  256. "name": "Status Test Project",
  257. "task_type": "image_classification",
  258. "data": [{"content": "http://example.com/1.jpg"}]
  259. },
  260. headers={"Authorization": f"Bearer {admin_token}"}
  261. )
  262. project_id = init_response.json()["project_id"]
  263. # Update status to configuring
  264. response = client.put(
  265. f"/api/projects/{project_id}/status",
  266. json={"status": "configuring"},
  267. headers={"Authorization": f"Bearer {admin_token}"}
  268. )
  269. assert response.status_code == 200
  270. assert response.json()["status"] == "configuring"
  271. def test_invalid_status_transition(self, client, admin_token):
  272. """Test invalid status transition: draft → completed."""
  273. # Create project
  274. init_response = client.post(
  275. "/api/external/projects/init",
  276. json={
  277. "name": "Invalid Status Test",
  278. "task_type": "image_classification",
  279. "data": [{"content": "http://example.com/1.jpg"}]
  280. },
  281. headers={"Authorization": f"Bearer {admin_token}"}
  282. )
  283. project_id = init_response.json()["project_id"]
  284. # Try invalid transition
  285. response = client.put(
  286. f"/api/projects/{project_id}/status",
  287. json={"status": "completed"},
  288. headers={"Authorization": f"Bearer {admin_token}"}
  289. )
  290. assert response.status_code == 400
  291. class TestProjectConfig:
  292. """Test project configuration."""
  293. def test_update_config_success(self, client, admin_token):
  294. """Test successful config update."""
  295. # Create project
  296. init_response = client.post(
  297. "/api/external/projects/init",
  298. json={
  299. "name": "Config Test Project",
  300. "task_type": "image_classification",
  301. "data": [{"content": "http://example.com/1.jpg"}]
  302. },
  303. headers={"Authorization": f"Bearer {admin_token}"}
  304. )
  305. project_id = init_response.json()["project_id"]
  306. # Update config
  307. new_config = """<View>
  308. <Image name="image" value="$image"/>
  309. <Choices name="choice" toName="image">
  310. <Choice value="Cat"/>
  311. <Choice value="Dog"/>
  312. </Choices>
  313. </View>"""
  314. response = client.put(
  315. f"/api/projects/{project_id}/config",
  316. json={"config": new_config},
  317. headers={"Authorization": f"Bearer {admin_token}"}
  318. )
  319. assert response.status_code == 200
  320. assert response.json()["status"] == "configuring"
  321. def test_update_config_invalid_xml(self, client, admin_token):
  322. """Test config update with invalid XML."""
  323. # Create project
  324. init_response = client.post(
  325. "/api/external/projects/init",
  326. json={
  327. "name": "Invalid Config Test",
  328. "task_type": "image_classification",
  329. "data": [{"content": "http://example.com/1.jpg"}]
  330. },
  331. headers={"Authorization": f"Bearer {admin_token}"}
  332. )
  333. project_id = init_response.json()["project_id"]
  334. # Try invalid config
  335. response = client.put(
  336. f"/api/projects/{project_id}/config",
  337. json={"config": "not xml at all"},
  338. headers={"Authorization": f"Bearer {admin_token}"}
  339. )
  340. assert response.status_code == 400
  341. class TestTaskDispatch:
  342. """Test task dispatch functionality."""
  343. def test_preview_assignment(self, client, admin_token, annotator_token):
  344. """Test assignment preview."""
  345. # Create project and set to ready
  346. init_response = client.post(
  347. "/api/external/projects/init",
  348. json={
  349. "name": "Dispatch Test Project",
  350. "task_type": "image_classification",
  351. "data": [
  352. {"content": "http://example.com/1.jpg"},
  353. {"content": "http://example.com/2.jpg"},
  354. {"content": "http://example.com/3.jpg"},
  355. {"content": "http://example.com/4.jpg"}
  356. ]
  357. },
  358. headers={"Authorization": f"Bearer {admin_token}"}
  359. )
  360. project_id = init_response.json()["project_id"]
  361. # Update config
  362. config = """<View>
  363. <Image name="image" value="$image"/>
  364. <Choices name="choice" toName="image">
  365. <Choice value="Cat"/>
  366. </Choices>
  367. </View>"""
  368. client.put(
  369. f"/api/projects/{project_id}/config",
  370. json={"config": config},
  371. headers={"Authorization": f"Bearer {admin_token}"}
  372. )
  373. # Set to ready
  374. client.put(
  375. f"/api/projects/{project_id}/status",
  376. json={"status": "ready"},
  377. headers={"Authorization": f"Bearer {admin_token}"}
  378. )
  379. # Get annotator list
  380. annotators_response = client.get(
  381. "/api/users/annotators",
  382. headers={"Authorization": f"Bearer {admin_token}"}
  383. )
  384. if annotators_response.status_code == 200 and len(annotators_response.json()) > 0:
  385. annotator_ids = [a["id"] for a in annotators_response.json()[:2]]
  386. # Preview assignment
  387. response = client.post(
  388. f"/api/tasks/preview-assignment/{project_id}",
  389. json={"user_ids": annotator_ids},
  390. headers={"Authorization": f"Bearer {admin_token}"}
  391. )
  392. assert response.status_code == 200
  393. data = response.json()
  394. assert data["unassigned_tasks"] == 4
  395. assert len(data["assignments"]) == len(annotator_ids)
  396. if __name__ == "__main__":
  397. pytest.main([__file__, "-v"])