diff --git a/api/teams/teams_service.py b/api/teams/teams_service.py index 514e23a..186921f 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -241,11 +241,12 @@ def remove_team(team_id): if hackathon_event_id: hackathon_db_id = get_hackathon_by_event_id(hackathon_event_id)["id"] event_collection = db.collection("hackathons").document(hackathon_db_id) - event_collection_dict = event_collection.get().to_dict() - - # Remove the team from the hackathon event + event_collection_dict = event_collection.get().to_dict() or {} + + # Remove the team from the hackathon event (tolerate docs with no + # `teams` key — same KeyError class as the queue_team linking bug) new_teams = [] - for t in event_collection_dict["teams"]: + for t in event_collection_dict.get("teams") or []: if t != team_doc: new_teams.append(t) @@ -381,6 +382,25 @@ def edit_team(json): "team_id": team_id } + +def _append_team_to_hackathon(db, hackathon_db_id, team_ref): + """Link a team DocumentReference into hackathons/{id}.teams[]. + + Hackathon docs created through the admin UI may have no ``teams`` key at + all (not even an empty list); indexing it directly raised KeyError after + the team doc was already inserted, leaving the team orphaned from its + event (Sep 2026, test event fall-2026). Idempotent: an already-linked + team is not appended twice. + """ + event_ref = db.collection("hackathons").document(hackathon_db_id) + event_dict = event_ref.get().to_dict() or {} + teams = list(event_dict.get("teams") or []) + existing_ids = {getattr(t, "id", None) for t in teams} + if getattr(team_ref, "id", None) not in existing_ids: + teams.append(team_ref) + event_ref.set({"teams": teams}, merge=True) + return teams + def queue_team(propel_user_id, json): """ Create a team and queue it for nonprofit pairing @@ -539,17 +559,7 @@ def queue_team(propel_user_id, json): # Link the team to the hackathon event hackathon_db_id = get_hackathon_by_event_id(hackathon_event_id)["id"] - event_collection = db.collection("hackathons").document(hackathon_db_id) - event_collection_dict = event_collection.get().to_dict() - - new_teams = [] - for t in event_collection_dict["teams"]: - new_teams.append(t) - new_teams.append(new_team_doc) - - event_collection.set({ - "teams": new_teams - }, merge=True) + _append_team_to_hackathon(db, hackathon_db_id, new_team_doc) # Clear the cache logger.info("Clearing cache for event_id=%s doc_id=%s", diff --git a/api/teams/tests/test_hackathon_team_linking.py b/api/teams/tests/test_hackathon_team_linking.py new file mode 100644 index 0000000..3a7f7ad --- /dev/null +++ b/api/teams/tests/test_hackathon_team_linking.py @@ -0,0 +1,77 @@ +""" +Regression tests for the KeyError: 'teams' crash in team creation. + +Hackathon docs created through the admin UI can lack a ``teams`` key +entirely. queue_team inserted the team doc, then did +``event_collection_dict["teams"]`` and blew up, leaving the new team +orphaned from its event (seen on test.ohack.dev with event fall-2026, +Sep 2026). The linking now goes through _append_team_to_hackathon, which +tolerates a missing key / missing doc and is idempotent. +""" +import os +from unittest.mock import MagicMock + +os.environ.setdefault("ENVIRONMENT", "test") + +import api.teams.teams_service as svc + + +def _db_with_hackathon(existing): + snapshot = MagicMock() + snapshot.to_dict.return_value = existing + event_ref = MagicMock() + event_ref.get.return_value = snapshot + collection = MagicMock() + collection.document.return_value = event_ref + db = MagicMock() + db.collection.return_value = collection + return db, event_ref + + +def _ref(doc_id): + r = MagicMock() + r.id = doc_id + return r + + +def test_links_team_when_hackathon_has_no_teams_key(): + db, event_ref = _db_with_hackathon({"event_id": "fall-2026", "title": "ASU Fall"}) + team = _ref("team-1") + + result = svc._append_team_to_hackathon(db, "hack-doc", team) + + assert result == [team] + written, kwargs = event_ref.set.call_args + assert written[0] == {"teams": [team]} + assert kwargs.get("merge") is True + + +def test_appends_to_existing_teams_list(): + existing_team = _ref("team-0") + db, event_ref = _db_with_hackathon({"teams": [existing_team]}) + team = _ref("team-1") + + result = svc._append_team_to_hackathon(db, "hack-doc", team) + + assert result == [existing_team, team] + assert event_ref.set.call_args[0][0]["teams"] == [existing_team, team] + + +def test_does_not_duplicate_an_already_linked_team(): + team = _ref("team-1") + db, event_ref = _db_with_hackathon({"teams": [team]}) + + result = svc._append_team_to_hackathon(db, "hack-doc", team) + + assert result == [team] + event_ref.set.assert_not_called() + + +def test_tolerates_missing_hackathon_snapshot(): + db, event_ref = _db_with_hackathon(None) + team = _ref("team-1") + + result = svc._append_team_to_hackathon(db, "hack-doc", team) + + assert result == [team] + assert event_ref.set.call_args[0][0] == {"teams": [team]}