2828
2929API = "https://api.linear.app/graphql"
3030_API_KEY = "lin_api_fake"
31+ # A canonical 8-4-4-4-12 UUID — the shape Linear's API requires for teamId.
32+ # Passing this bypasses team resolution (no teams lookup round-trip).
33+ _TEAM_UUID = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
3134
3235
3336def _args (** extra : Any ) -> dict [str , Any ]:
@@ -38,6 +41,13 @@ def _gql(body: dict[str, Any]) -> dict[str, Any]:
3841 return body
3942
4043
44+ def _request_bodies (httpx_mock : Any ) -> list [dict [str , Any ]]:
45+ """Decode every captured request's JSON body, in order."""
46+ import json
47+
48+ return [json .loads (r .content .decode ()) for r in httpx_mock .get_requests ()]
49+
50+
4151class TestManifest :
4252 def test_manifest_exposes_seven_actions (self ) -> None :
4353 assert len (manifest .actions ) == 7
@@ -193,23 +203,28 @@ async def test_search_issues_filter_is_a_variable_not_interpolated(
193203 )
194204 result = SearchIssuesOutput .model_validate (
195205 await search_issues .ainvoke (
196- _args (team_id = "T1" , query = "bug" , label_names = ["urgent" , "bug" ], limit = 10 )
206+ _args (
207+ team_id = _TEAM_UUID ,
208+ query = "bug" ,
209+ label_names = ["urgent" , "bug" ],
210+ limit = 10 ,
211+ )
197212 )
198213 )
199214 assert result .success is True
200215 assert result .count == 1
201216 # The filter is sent as a typed $filter variable, not spliced into the query.
202217 assert captured ["variables" ]["filter" ] == {
203218 "title" : {"containsIgnoreCase" : "bug" },
204- "team" : {"id" : {"eq" : "T1" }},
219+ "team" : {"id" : {"eq" : _TEAM_UUID }},
205220 "labels" : {"name" : {"in" : ["urgent" , "bug" ]}},
206221 }
207222 assert captured ["variables" ]["first" ] == 10
208223 assert captured ["variables" ]["includeArchived" ] is False
209224 assert captured ["variables" ]["orderBy" ] == "updatedAt"
210225 # User-supplied filter values never appear in the query text.
211226 assert "bug" not in captured ["query" ]
212- assert "T1" not in captured ["query" ]
227+ assert _TEAM_UUID not in captured ["query" ]
213228 assert "$filter: IssueFilter" in captured ["query" ]
214229
215230
@@ -245,13 +260,121 @@ async def test_create_issue(httpx_mock: Any) -> None:
245260 },
246261 )
247262 result = CreateIssueOutput .model_validate (
248- await create_issue .ainvoke (_args (team_id = "T1" , title = "New bug" , priority = 2 ))
263+ await create_issue .ainvoke (
264+ _args (team_id = _TEAM_UUID , title = "New bug" , priority = 2 )
265+ )
249266 )
250267 assert result .success is True
251268 assert result .issue is not None
252269 assert result .issue ["identifier" ] == "BE-99"
253270
254271
272+ @pytest .mark .asyncio
273+ async def test_create_issue_resolves_team_key (httpx_mock : Any ) -> None :
274+ """Passing a team KEY (or name) resolves to the team UUID, and the
275+ issueCreate mutation receives the UUID — not the raw key. This is the
276+ fix for 'teamId must be a UUID' when users pass the human-facing key."""
277+ # First request: the team-resolution lookup.
278+ httpx_mock .add_response (
279+ method = "POST" ,
280+ url = API ,
281+ json = {
282+ "data" : {
283+ "teams" : {
284+ "nodes" : [
285+ {
286+ "id" : "11111111-1111-4111-8111-111111111111" ,
287+ "name" : "Frontend" ,
288+ "key" : "FE" ,
289+ },
290+ {"id" : _TEAM_UUID , "name" : "Engineering" , "key" : "ENG" },
291+ ],
292+ "pageInfo" : {"hasNextPage" : False , "endCursor" : None },
293+ }
294+ }
295+ },
296+ )
297+ # Second request: the create mutation.
298+ httpx_mock .add_response (
299+ method = "POST" ,
300+ url = API ,
301+ json = {
302+ "data" : {
303+ "issueCreate" : {
304+ "success" : True ,
305+ "issue" : {"id" : "I1" , "identifier" : "ENG-1" , "title" : "x" },
306+ }
307+ }
308+ },
309+ )
310+ # Lowercase 'eng' also proves the match is case-insensitive against 'ENG'.
311+ result = CreateIssueOutput .model_validate (
312+ await create_issue .ainvoke (_args (team_id = "eng" , title = "x" ))
313+ )
314+ assert result .success is True
315+ assert result .issue is not None
316+ assert result .issue ["identifier" ] == "ENG-1"
317+
318+ bodies = _request_bodies (httpx_mock )
319+ assert len (bodies ) == 2
320+ # The mutation carried the resolved UUID, never the raw 'eng' key.
321+ assert bodies [1 ]["variables" ]["input" ]["teamId" ] == _TEAM_UUID
322+
323+
324+ @pytest .mark .asyncio
325+ async def test_create_issue_unknown_team_lists_available (httpx_mock : Any ) -> None :
326+ """An unresolvable team reference fails clearly and lists the available
327+ teams, rather than firing a doomed create mutation."""
328+ httpx_mock .add_response (
329+ method = "POST" ,
330+ url = API ,
331+ json = {
332+ "data" : {
333+ "teams" : {
334+ "nodes" : [
335+ {"id" : _TEAM_UUID , "name" : "Engineering" , "key" : "ENG" },
336+ ],
337+ "pageInfo" : {"hasNextPage" : False , "endCursor" : None },
338+ }
339+ }
340+ },
341+ )
342+ result = CreateIssueOutput .model_validate (
343+ await create_issue .ainvoke (_args (team_id = "NOPE" , title = "x" ))
344+ )
345+ assert result .success is False
346+ assert result .error is not None
347+ assert "No Linear team matches" in result .error
348+ assert "ENG" in result .error # available teams are listed for the caller
349+ # Only the resolution lookup happened; no create mutation was sent.
350+ assert len (httpx_mock .get_requests ()) == 1
351+
352+
353+ @pytest .mark .asyncio
354+ async def test_create_issue_uuid_skips_resolution (httpx_mock : Any ) -> None :
355+ """A UUID team_id is forwarded directly — no teams lookup round-trip."""
356+ httpx_mock .add_response (
357+ method = "POST" ,
358+ url = API ,
359+ json = {
360+ "data" : {
361+ "issueCreate" : {
362+ "success" : True ,
363+ "issue" : {"id" : "I1" , "identifier" : "ENG-1" , "title" : "x" },
364+ }
365+ }
366+ },
367+ )
368+ result = CreateIssueOutput .model_validate (
369+ await create_issue .ainvoke (_args (team_id = _TEAM_UUID , title = "x" ))
370+ )
371+ assert result .success is True
372+ # Exactly one request — the mutation. Resolution was skipped.
373+ assert len (httpx_mock .get_requests ()) == 1
374+ body = _request_bodies (httpx_mock )[0 ]
375+ assert body ["variables" ]["input" ]["teamId" ] == _TEAM_UUID
376+
377+
255378@pytest .mark .asyncio
256379async def test_create_issue_mutation_failure (httpx_mock : Any ) -> None :
257380 httpx_mock .add_response (
@@ -260,7 +383,7 @@ async def test_create_issue_mutation_failure(httpx_mock: Any) -> None:
260383 json = {"data" : {"issueCreate" : {"success" : False , "issue" : None }}},
261384 )
262385 result = CreateIssueOutput .model_validate (
263- await create_issue .ainvoke (_args (team_id = "T1" , title = "x" ))
386+ await create_issue .ainvoke (_args (team_id = _TEAM_UUID , title = "x" ))
264387 )
265388 assert result .success is False
266389 assert result .error is not None and "create" in result .error
@@ -270,9 +393,10 @@ async def test_create_issue_mutation_failure(httpx_mock: Any) -> None:
270393async def test_create_issue_argument_validation_surfaces_reason (
271394 httpx_mock : Any ,
272395) -> None :
273- """A team KEY passed as team_id yields Linear's generic 'Argument
274- Validation Error'; the real reason lives in extensions and must be
275- surfaced so the failure is debuggable."""
396+ """When the mutation itself returns Linear's generic 'Argument
397+ Validation Error', the real reason lives in extensions and must be
398+ surfaced so the failure is debuggable. (team_id is a UUID here, so
399+ resolution is skipped and the request reaches the mutation.)"""
276400 httpx_mock .add_response (
277401 method = "POST" ,
278402 url = API ,
@@ -285,21 +409,21 @@ async def test_create_issue_argument_validation_surfaces_reason(
285409 "extensions" : {
286410 "code" : "INTERNAL_SERVER_ERROR" ,
287411 "type" : "invalid_input" ,
288- "userPresentableMessage" : "teamId must be a UUID " ,
412+ "userPresentableMessage" : "dueDate must be a valid date " ,
289413 "userError" : True ,
290414 },
291415 }
292416 ],
293417 },
294418 )
295419 result = CreateIssueOutput .model_validate (
296- await create_issue .ainvoke (_args (team_id = "ENG" , title = "x" ))
420+ await create_issue .ainvoke (_args (team_id = _TEAM_UUID , title = "x" ))
297421 )
298422 assert result .success is False
299423 assert result .error is not None
300424 # Generic wrapper preserved, actionable reason appended.
301425 assert "Argument Validation Error" in result .error
302- assert "teamId must be a UUID " in result .error
426+ assert "dueDate must be a valid date " in result .error
303427
304428
305429@pytest .mark .asyncio
@@ -333,7 +457,7 @@ async def test_graphql_error_falls_back_to_validation_constraints(
333457 },
334458 )
335459 result = CreateIssueOutput .model_validate (
336- await create_issue .ainvoke (_args (team_id = "T1" , title = "x" , priority = 9 ))
460+ await create_issue .ainvoke (_args (team_id = _TEAM_UUID , title = "x" , priority = 9 ))
337461 )
338462 assert result .success is False
339463 assert result .error is not None
@@ -352,7 +476,7 @@ async def test_graphql_error_without_extensions_is_unchanged(
352476 json = {"errors" : [{"message" : "Authentication required" }]},
353477 )
354478 result = CreateIssueOutput .model_validate (
355- await create_issue .ainvoke (_args (team_id = "T1" , title = "x" ))
479+ await create_issue .ainvoke (_args (team_id = _TEAM_UUID , title = "x" ))
356480 )
357481 assert result .success is False
358482 assert result .error == "GraphQL errors: Authentication required"
@@ -404,7 +528,7 @@ async def test_list_projects(httpx_mock: Any) -> None:
404528 },
405529 )
406530 result = ListProjectsOutput .model_validate (
407- await list_projects .ainvoke (_args (team_id = "T1" , limit = 5 ))
531+ await list_projects .ainvoke (_args (team_id = _TEAM_UUID , limit = 5 ))
408532 )
409533 assert result .success is True
410534 assert result .count == 1
@@ -426,7 +550,7 @@ async def test_create_project(httpx_mock: Any) -> None:
426550 },
427551 )
428552 result = CreateProjectOutput .model_validate (
429- await create_project .ainvoke (_args (team_id = "T1" , name = "New project" ))
553+ await create_project .ainvoke (_args (team_id = _TEAM_UUID , name = "New project" ))
430554 )
431555 assert result .success is True
432556 assert result .project is not None
0 commit comments