feat(backend): refactor mono repository

This commit is contained in:
2025-08-27 11:04:56 -04:00
parent d0dbba21fb
commit be1c729220
37 changed files with 2534 additions and 452 deletions

View File

@@ -0,0 +1,383 @@
# tests/repositories/test_member.py
import datetime as dt
from typing import List
import pytest
from backend.models import Member as MemberModel, ScheduleStatus
from backend.repositories import MemberRepository
# ----------------------------------------------------------------------
# Helper: a few sample members we can reuse across tests
# ----------------------------------------------------------------------
@pytest.fixture
def sample_members() -> List[MemberModel]:
"""Return a list of MemberModel objects (not yet persisted)."""
return [
MemberModel(
MemberId=-1,
FirstName="Alice",
LastName="Anderson",
Email="alice@example.com",
PhoneNumber="5551111",
ClassificationId=1,
Notes=None,
IsActive=1,
LastScheduledAt=None,
LastAcceptedAt=None,
LastDeclinedAt=None,
DeclineStreak=0,
),
MemberModel(
MemberId=-1,
FirstName="Bob",
LastName="Baker",
Email="bob@example.com",
PhoneNumber="5552222",
ClassificationId=2,
Notes="VIP",
IsActive=1,
LastScheduledAt=dt.datetime(2025, 8, 20, 10, 0, 0),
LastAcceptedAt=dt.datetime(2025, 8, 19, 9, 30, 0),
LastDeclinedAt=None,
DeclineStreak=0,
),
MemberModel(
MemberId=-1,
FirstName="Carol",
LastName="Carter",
Email=None,
PhoneNumber=None,
ClassificationId=1,
Notes=None,
IsActive=0, # inactive useful for filter tests
LastScheduledAt=None,
LastAcceptedAt=None,
LastDeclinedAt=None,
DeclineStreak=0,
),
]
# ----------------------------------------------------------------------
# Fixture to wipe the Members table (used by tests that need a clean slate)
# ----------------------------------------------------------------------
@pytest.fixture
def clean_members(member_repo: MemberRepository):
"""
Delete *all* rows from the Members table **and** any rows that
reference it (ServiceAvailability). The serviceavailability tests
rely on the seeded Alice/Bob rows, so we only invoke this fixture
in the memberrepo tests that need isolation.
"""
# 1⃣ Remove dependent rows first otherwise the FK constraint blocks us.
member_repo.db.execute(
f"DELETE FROM ServiceAvailability"
) # commit happens inside `execute`
# 2⃣ Now we can safely delete the members themselves.
member_repo.db.execute(
f"DELETE FROM {member_repo._TABLE}"
)
member_repo.db._conn.commit()
# ----------------------------------------------------------------------
# Helper to build a MemberModel with explicit timestamps.
# ----------------------------------------------------------------------
def make_member(
repo: MemberRepository,
first_name: str,
last_name: str,
*,
classification_id: int = 1,
is_active: int = 1,
accepted_at: str | None = None,
scheduled_at: str | None = None,
declined_at: str | None = None,
decline_streak: int = 0,
) -> MemberModel:
"""Insert a member and then manually set the optional timestamp columns."""
m = repo.create(
first_name=first_name,
last_name=last_name,
email=None,
phone_number=None,
classification_id=classification_id,
notes=None,
is_active=is_active,
)
# Directly update the row so we can control the timestamps without
# invoking the repositorys higherlevel helpers (which would reset
# other fields).
sql = f"""
UPDATE {repo._TABLE}
SET
LastAcceptedAt = ?,
LastScheduledAt = ?,
LastDeclinedAt = ?,
DeclineStreak = ?
WHERE {repo._PK} = ?
"""
repo.db.execute(
sql,
(
accepted_at,
scheduled_at,
declined_at,
decline_streak,
m.MemberId,
),
)
# Refresh the model from the DB so the attributes reflect the changes.
return repo.get_by_id(m.MemberId) # type: ignore[return-value]
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create & get_by_id
# ----------------------------------------------------------------------
def test_create_and_get_by_id(member_repo: MemberRepository):
member = member_repo.create(
first_name="Diana",
last_name="Doe",
email="diana@example.com",
phone_number="5553333",
classification_id=3,
notes="New recruit",
is_active=1,
)
# Primary key should be a positive integer (AUTOINCREMENT starts at 1)
assert isinstance(member.MemberId, int) and member.MemberId > 0
# Retrieve the same row
fetched = member_repo.get_by_id(member.MemberId)
assert fetched is not None
assert fetched.FirstName == "Diana"
assert fetched.LastName == "Doe"
assert fetched.Email == "diana@example.com"
assert fetched.ClassificationId == 3
assert fetched.IsActive == 1
assert fetched.Notes == "New recruit"
def test_get_by_id_returns_none_when_missing(member_repo: MemberRepository):
"""A PK that does not exist must return ``None`` (no exception)."""
assert member_repo.get_by_id(9999) is None
# ----------------------------------------------------------------------
# 2⃣ list_all bulk insertion + retrieval
# ----------------------------------------------------------------------
def test_list_all(
member_repo: MemberRepository,
sample_members: List[MemberModel],
clean_members, # ensure we start from an empty table
):
for m in sample_members:
member_repo.create(
first_name=m.FirstName,
last_name=m.LastName,
email=m.Email,
phone_number=m.PhoneNumber,
classification_id=m.ClassificationId,
notes=m.Notes,
is_active=m.IsActive,
)
all_members = member_repo.list_all()
# Because we cleared the table first, we expect exactly the three we added.
assert len(all_members) == 3
# Spotcheck that each name appears
names = {(m.FirstName, m.LastName) for m in all_members}
assert ("Alice", "Anderson") in names
assert ("Bob", "Baker") in names
assert ("Carol", "Carter") in names
# ----------------------------------------------------------------------
# 3⃣ get_by_classification_ids filter by classification list
# ----------------------------------------------------------------------
def test_get_by_classification_ids(
member_repo: MemberRepository,
sample_members: List[MemberModel],
clean_members,
):
for m in sample_members:
member_repo.create(
first_name=m.FirstName,
last_name=m.LastName,
email=m.Email,
phone_number=m.PhoneNumber,
classification_id=m.ClassificationId,
notes=m.Notes,
is_active=m.IsActive,
)
# Classification 1 → Alice + Carol (2 rows)
result = member_repo.get_by_classification_ids([1])
assert len(result) == 2
assert {r.FirstName for r in result} == {"Alice", "Carol"}
# Classification 2 → only Bob
result = member_repo.get_by_classification_ids([2])
assert len(result) == 1
assert result[0].FirstName == "Bob"
# Both classifications → all three
result = member_repo.get_by_classification_ids([1, 2])
assert len(result) == 3
def test_candidate_queue_obeys_boost_and_timestamp_sorting(
member_repo: MemberRepository,
):
"""
Verify that ``candidate_queue`` respects:
1⃣ The boost clause (low ``DeclineStreak`` + recent ``LastDeclinedAt``).
2⃣ ``LastAcceptedAt`` ASC (oldest first, ``NULL`` → farpast).
3⃣ ``LastScheduledAt`` ASC (same handling).
The default boost window is 2days (172800seconds).
Additional rule (as stated in the docstring):
*Members whose ``LastAcceptedAt`` is NULL should appear **before** members
that have a nonNULL acceptance date.*
"""
# --------------------------------------------------------------
# 0⃣ Remove any ServiceAvailability rows that reference the seeded
# members, then delete the seeded members themselves.
# --------------------------------------------------------------
member_repo.db.execute("DELETE FROM ServiceAvailability")
member_repo.db.execute(
f"DELETE FROM {member_repo._TABLE} WHERE MemberId IN (1, 2)"
)
member_repo.db._conn.commit()
# --------------------------------------------------------------
# 1⃣ Build a diverse set of members.
# --------------------------------------------------------------
# ── A active, no timestamps (baseline, NULL acceptance)
a = make_member(member_repo, "Alice", "Anderson")
# ── B active, accepted yesterday (nonNULL acceptance)
yesterday = (dt.datetime.utcnow() - dt.timedelta(days=1)).strftime(
"%Y-%m-%d %H:%M:%S"
)
b = make_member(
member_repo,
"Bob",
"Baker",
accepted_at=yesterday,
)
# ── C active, declined **today** with a low streak (boost candidate)
today_iso = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
c = make_member(
member_repo,
"Carol",
"Clark",
declined_at=today_iso,
decline_streak=1, # < 2 → qualifies for boost
)
# ── D active, declined **3days ago** (outside the 2day boost window,
# still NULL acceptance)
three_days_ago = (dt.datetime.utcnow() - dt.timedelta(days=3)).strftime(
"%Y-%m-%d %H:%M:%S"
)
d = make_member(
member_repo,
"Dave",
"Davis",
declined_at=three_days_ago,
decline_streak=1,
)
# ── E **inactive** member should never appear when only_active=True.
e = make_member(
member_repo,
"Eve",
"Evans",
is_active=0,
)
# --------------------------------------------------------------
# 2⃣ Pull the queue (default: only_active=True, boost_seconds=2days)
# --------------------------------------------------------------
q = member_repo.candidate_queue(classification_ids=[1])
# --------------------------------------------------------------
# 3⃣ Expected order (explain each step):
# --------------------------------------------------------------
# • Boosted members first → Carol (recent decline, streak < 2)
# • Then all members whose ``LastAcceptedAt`` is NULL,
# ordered by ``LastScheduledAt`` (both are NULL, so fallback to PK order):
# → Alice, then Dave
# • Finally members with a nonNULL acceptance date → Bob
# • Eve is inactive → omitted.
expected_first_names = ["Carol", "Alice", "Dave", "Bob"]
assert [m.FirstName for m in q] == expected_first_names
# ----------------------------------------------------------------------
# 5⃣ touch_last_scheduled updates the timestamp column
# ----------------------------------------------------------------------
def test_touch_last_scheduled_updates_timestamp(member_repo: MemberRepository):
member = member_repo.create(
first_name="Eve",
last_name="Evans",
email=None,
phone_number=None,
classification_id=4,
notes=None,
is_active=1,
)
assert member.LastScheduledAt is None
# Call the helper it should set LastScheduledAt to the current UTC time.
member_repo.touch_last_scheduled(member.MemberId)
refreshed = member_repo.get_by_id(member.MemberId)
assert refreshed is not None
assert refreshed.LastScheduledAt is not None
# SQLite stores timestamps as ISO8601 strings; parsing should succeed.
dt.datetime.fromisoformat(refreshed.LastScheduledAt)
# ----------------------------------------------------------------------
# 6⃣ set_last_declined records decline date and increments streak
# ----------------------------------------------------------------------
def test_set_last_declined_resets_streak_and_records_date(member_repo: MemberRepository):
member = member_repo.create(
first_name="Frank",
last_name="Foster",
email=None,
phone_number=None,
classification_id=4,
notes=None,
is_active=1,
)
# Initial state
assert member.DeclineStreak == 0
assert member.LastDeclinedAt is None
# Simulate a decline today.
today_iso = dt.date.today().isoformat()
member_repo.set_last_declined(member.MemberId, today_iso)
refreshed = member_repo.get_by_id(member.MemberId)
assert refreshed.DeclineStreak == 1
assert refreshed.LastDeclinedAt == today_iso
# Simulate a second decline tomorrow streak should increase again.
tomorrow_iso = (dt.date.today() + dt.timedelta(days=1)).isoformat()
member_repo.set_last_declined(member.MemberId, tomorrow_iso)
refreshed2 = member_repo.get_by_id(member.MemberId)
assert refreshed2.DeclineStreak == 2
assert refreshed2.LastDeclinedAt == tomorrow_iso