test_external_api_integration.py 15 KB

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