Skip to content

Commit 1bd2e6b

Browse files
nanotaboadaCopilot
andcommitted
fix(api): address code review findings (#66)
- Raise HTTP 500 if create_async returns None (DB failure on POST) - Capture bool result from update_async/delete_async; raise HTTP 500 on False; only clear cache after confirmed success - Fix DELETE test to self-create player in Arrange (no inter-test dep) - Replace all(UUID(...)) with explicit _is_valid_uuid() validator - Redeclare PlayerResponseModel fields with id first (controls JSON order) - Add type hints and Google-style docstrings to HyphenatedUUID methods; rename unused dialect params to _dialect - Align service logger with main.py: getLogger("uvicorn.error") with reference comment (Kludex/uvicorn#562) - Replace logger.error(f"...") with logger.exception("...: %s", error) in all three SQLAlchemyError handlers - Replace EN dashes with ASCII hyphens in seed_002 log/argparse strings - Replace logger.error with logger.exception in seed_001/seed_002 sqlite3.Error handlers - Remove inline comments from .coveragerc exclude_lines (configparser treats them as part of the regex, preventing pragma: no cover from matching) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 216b0a0 commit 1bd2e6b

File tree

9 files changed

+109
-23
lines changed

9 files changed

+109
-23
lines changed

.coveragerc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ omit =
1010

1111
[report]
1212
exclude_lines =
13-
pragma: no cover # Standard pragma to intentionally skip lines
14-
if __name__ == .__main__.: # Skips CLI bootstrapping code
15-
raise NotImplementedError # Often placeholder stubs not meant to be covered
13+
pragma: no cover
14+
if __name__ == .__main__.:
15+
raise NotImplementedError

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,25 @@ This project uses famous football coaches as release codenames, following an A-Z
5858
- POST conflict detection changed from ID lookup to `squad_number` uniqueness check (#66)
5959
- `tests/player_stub.py` updated with UUID-based test fixtures (#66)
6060
- `tests/test_main.py` updated to assert UUID presence and format in API responses (#66)
61+
- `PlayerResponseModel` redeclared with `id` as first field to control JSON serialization order (#66)
62+
- `HyphenatedUUID` methods now have full type annotations and Google-style docstrings; unused `dialect` params renamed to `_dialect` (#66)
63+
- Service logger changed from `getLogger("uvicorn")` to `getLogger("uvicorn.error")`, aligned with `main.py` (#66)
64+
- `logger.error(f"...")` replaced with `logger.exception("...: %s", error)` in all `SQLAlchemyError` handlers (#66)
65+
- EN dashes replaced with ASCII hyphens in `seed_002` log and argparse strings (#66)
66+
- `logger.error` replaced with `logger.exception` in `sqlite3.Error` handlers in `seed_001` and `seed_002` (#66)
6167

6268
### Deprecated
6369

6470
### Removed
6571

6672
### Fixed
6773

74+
- POST/PUT/DELETE routes now raise `HTTP 500` on DB failure instead of silently returning success (#66)
75+
- Cache cleared only after confirmed successful create, update, or delete (#66)
76+
- DELETE test is now self-contained; no longer depends on POST test having run first (#66)
77+
- UUID assertion in GET all test replaced with explicit `_is_valid_uuid()` validator (#66)
78+
- Emiliano Martínez `middleName` corrected from `""` to `None` in `seed_001` (#66)
79+
6880
### Security
6981

7082
---

models/player_model.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,35 @@ class PlayerRequestModel(MainModel):
6666
starting11: Optional[bool]
6767

6868

69-
class PlayerResponseModel(PlayerRequestModel):
69+
class PlayerResponseModel(MainModel):
7070
"""
7171
Pydantic model representing a football Player with a UUID for Retrieve operations.
7272
7373
Attributes:
74-
id (UUID): The unique identifier for the Player (UUID v4).
74+
id (UUID): The unique identifier for the Player (UUID v4 for API-created
75+
records, UUID v5 for migration-seeded records).
76+
first_name (str): The first name of the Player.
77+
middle_name (Optional[str]): The middle name of the Player, if any.
78+
last_name (str): The last name of the Player.
79+
date_of_birth (Optional[str]): The date of birth of the Player, if provided.
80+
squad_number (int): The unique squad number assigned to the Player.
81+
position (str): The playing position of the Player.
82+
abbr_position (Optional[str]): The abbreviated form of the Player's position,
83+
if any.
84+
team (Optional[str]): The team to which the Player belongs, if any.
85+
league (Optional[str]): The league where the team plays, if any.
86+
starting11 (Optional[bool]): Indicates if the Player is in the starting 11,
87+
if provided.
7588
"""
7689

7790
id: UUID
91+
first_name: str
92+
middle_name: Optional[str]
93+
last_name: str
94+
date_of_birth: Optional[str]
95+
squad_number: int
96+
position: str
97+
abbr_position: Optional[str]
98+
team: Optional[str]
99+
league: Optional[str]
100+
starting11: Optional[bool]

routes/player_route.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ async def post_async(
6969
if existing:
7070
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
7171
player = await player_service.create_async(async_session, player_model)
72+
if player is None: # pragma: no cover
73+
raise HTTPException(
74+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
75+
detail="Failed to create the Player due to a database error.",
76+
)
7277
await simple_memory_cache.clear(CACHE_KEY)
7378
return player
7479

@@ -198,7 +203,12 @@ async def put_async(
198203
player = await player_service.retrieve_by_id_async(async_session, player_id)
199204
if not player:
200205
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
201-
await player_service.update_async(async_session, player_id, player_model)
206+
updated = await player_service.update_async(async_session, player_id, player_model)
207+
if not updated: # pragma: no cover
208+
raise HTTPException(
209+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
210+
detail="Failed to update the Player due to a database error.",
211+
)
202212
await simple_memory_cache.clear(CACHE_KEY)
203213

204214

@@ -229,5 +239,10 @@ async def delete_async(
229239
player = await player_service.retrieve_by_id_async(async_session, player_id)
230240
if not player:
231241
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
232-
await player_service.delete_async(async_session, player_id)
242+
deleted = await player_service.delete_async(async_session, player_id)
243+
if not deleted: # pragma: no cover
244+
raise HTTPException(
245+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
246+
detail="Failed to delete the Player due to a database error.",
247+
)
233248
await simple_memory_cache.clear(CACHE_KEY)

schemas/player_schema.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Used for async database CRUD operations in the application.
77
"""
88

9+
from typing import Optional, Union
910
from uuid import UUID, uuid4
1011
from sqlalchemy import Column, String, Integer, Boolean, TypeDecorator
1112
from databases.player_database import Base
@@ -20,14 +21,35 @@ class HyphenatedUUID(TypeDecorator):
2021
impl = String(36)
2122
cache_ok = True
2223

23-
def process_bind_param(self, value, dialect):
24+
def process_bind_param(
25+
self, value: Optional[Union[UUID, str]], _dialect
26+
) -> Optional[str]:
27+
"""Convert a UUID or string to a hyphenated UUID string for storage.
28+
29+
Args:
30+
value: A UUID object, a UUID string, or None.
31+
_dialect: The SQLAlchemy dialect (unused).
32+
33+
Returns:
34+
A hyphenated UUID string (e.g. '550e8400-e29b-41d4-a716-446655440000'),
35+
or None if value is None.
36+
"""
2437
if value is None:
2538
return None
2639
if isinstance(value, UUID):
2740
return str(value)
2841
return str(UUID(str(value)))
2942

30-
def process_result_value(self, value, dialect):
43+
def process_result_value(self, value: Optional[str], _dialect) -> Optional[UUID]:
44+
"""Convert a stored hyphenated UUID string back to a Python UUID object.
45+
46+
Args:
47+
value: A hyphenated UUID string, or None.
48+
_dialect: The SQLAlchemy dialect (unused).
49+
50+
Returns:
51+
A Python UUID object, or None if value is None.
52+
"""
3153
if value is None:
3254
return None
3355
return UUID(value)

services/player_service.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
from models.player_model import PlayerRequestModel
2626
from schemas.player_schema import Player
2727

28-
logger = logging.getLogger("uvicorn")
28+
# https://github.com/encode/uvicorn/issues/562
29+
logger = logging.getLogger("uvicorn.error")
2930

3031
# Create -----------------------------------------------------------------------
3132

@@ -52,7 +53,7 @@ async def create_async(
5253
await async_session.refresh(player)
5354
return player
5455
except SQLAlchemyError as error:
55-
logger.error(f"Error trying to create the Player: {error}")
56+
logger.exception("Error trying to create the Player: %s", error)
5657
await async_session.rollback()
5758
return None
5859

@@ -146,7 +147,7 @@ async def update_async(
146147
await async_session.commit()
147148
return True
148149
except SQLAlchemyError as error:
149-
logger.error(f"Error trying to update the Player: {error}")
150+
logger.exception("Error trying to update the Player: %s", error)
150151
await async_session.rollback()
151152
return False
152153

@@ -171,6 +172,6 @@ async def delete_async(async_session: AsyncSession, player_id: UUID) -> bool:
171172
await async_session.commit()
172173
return True
173174
except SQLAlchemyError as error:
174-
logger.error(f"Error trying to delete the Player: {error}")
175+
logger.exception("Error trying to delete the Player: %s", error)
175176
await async_session.rollback()
176177
return False

tests/test_main.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222

2323
PATH = "/players/"
2424

25+
26+
def _is_valid_uuid(value: str) -> bool:
27+
"""Return True if value is a well-formed UUID string, False otherwise."""
28+
try:
29+
UUID(value)
30+
return True
31+
except ValueError:
32+
return False
33+
34+
2535
# GET /health/ -----------------------------------------------------------------
2636

2737

@@ -70,7 +80,9 @@ def test_request_get_players_response_body_each_player_has_uuid(client):
7080
response = client.get(PATH)
7181
# Assert
7282
players = response.json()
73-
assert all(UUID(player["id"]) for player in players) # UUID v5 (migration-seeded)
83+
assert all(
84+
_is_valid_uuid(player["id"]) for player in players
85+
) # UUID v5 (migration-seeded)
7486

7587

7688
# GET /players/{player_id} -----------------------------------------------------
@@ -229,9 +241,10 @@ def test_request_delete_player_id_unknown_response_status_not_found(client):
229241

230242
def test_request_delete_player_id_existing_response_status_no_content(client):
231243
"""DELETE /players/{player_id} with existing UUID returns 204 No Content"""
232-
# Arrange
233-
squad_number = nonexistent_player().squad_number # created by POST test
234-
lookup_response = client.get(PATH + "squadnumber/" + str(squad_number))
244+
# Arrange — create the player to be deleted, then resolve its UUID
245+
player = nonexistent_player()
246+
client.post(PATH, json=player.__dict__)
247+
lookup_response = client.get(PATH + "squadnumber/" + str(player.squad_number))
235248
player_id = lookup_response.json()["id"]
236249
# Act
237250
response = client.delete(PATH + str(player_id))

tools/seed_001_starting_eleven.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
{
4848
"id": "b04965e6-a9bb-591f-8f8a-1adcb2c8dc39",
4949
"firstName": "Emiliano",
50-
"middleName": "",
50+
"middleName": None,
5151
"lastName": "Martínez",
5252
"dateOfBirth": "1992-09-02T00:00:00.000Z",
5353
"squadNumber": 23,
@@ -280,7 +280,7 @@ def run(db_path: Path) -> None:
280280

281281
except sqlite3.Error as exc:
282282
conn.rollback()
283-
logger.error("Migration failed: %s", exc)
283+
logger.exception("Migration failed: %s", exc)
284284
sys.exit(1)
285285
finally:
286286
conn.close()

tools/seed_002_substitutes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ def run(db_path: Path) -> None:
271271

272272
if _already_migrated(conn):
273273
logger.info(
274-
"Migration 002 already applied all substitute UUIDs present. "
274+
"Migration 002 already applied - all substitute UUIDs present. "
275275
"Skipping."
276276
)
277277
return
@@ -280,19 +280,19 @@ def run(db_path: Path) -> None:
280280
conn.executemany(INSERT_SQL, SUBSTITUTES)
281281
conn.commit()
282282

283-
logger.info("Migration 002 Substitutes completed successfully.")
283+
logger.info("Migration 002 - Substitutes completed successfully.")
284284

285285
except sqlite3.Error as exc:
286286
conn.rollback()
287-
logger.error("Migration failed: %s", exc)
287+
logger.exception("Migration failed: %s", exc)
288288
sys.exit(1)
289289
finally:
290290
conn.close()
291291

292292

293293
def _parse_args() -> argparse.Namespace:
294294
parser = argparse.ArgumentParser(
295-
description="Migration 002 seed substitute players with UUID PKs."
295+
description="Migration 002 - seed substitute players with UUID PKs."
296296
)
297297
parser.add_argument(
298298
"--db-path",

0 commit comments

Comments
 (0)