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,93 @@
# backend/tests/repositories/test_classification.py
# ------------------------------------------------------------
# Pytest suite for the ClassificationRepository.
# ------------------------------------------------------------
import pytest
from backend.models import Classification as ClassificationModel
from backend.repositories import ClassificationRepository
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create & get_by_id
# ----------------------------------------------------------------------
def test_create_and_get_by_id(classification_repo):
new = classification_repo.create("Countertenor")
assert isinstance(new.ClassificationId, int) and new.ClassificationId > 0
assert new.ClassificationName == "Countertenor"
fetched = classification_repo.get_by_id(new.ClassificationId)
assert fetched is not None
assert fetched.ClassificationId == new.ClassificationId
assert fetched.ClassificationName == "Countertenor"
# ----------------------------------------------------------------------
# 2⃣ Lookup by name (exact match)
# ----------------------------------------------------------------------
def test_find_by_name_existing(classification_repo):
soprano = classification_repo.find_by_name("Soprano")
assert soprano is not None
assert soprano.ClassificationName == "Soprano"
def test_find_by_name_missing(classification_repo):
missing = classification_repo.find_by_name("Bass")
assert missing is None
# ----------------------------------------------------------------------
# 3⃣ List all classifications (ordered alphabetically)
# ----------------------------------------------------------------------
def test_list_all(classification_repo):
all_rows: list[ClassificationModel] = classification_repo.list_all()
assert len(all_rows) == 4 # the four seeded rows
names = [row.ClassificationName for row in all_rows]
assert names == sorted(names)
# ----------------------------------------------------------------------
# 4⃣ Idempotent helper ensure_exists
# ----------------------------------------------------------------------
def test_ensure_exists_creates_when_missing(classification_repo):
before = classification_repo.find_by_name("Bass")
assert before is None
bass = classification_repo.ensure_exists("Bass")
assert isinstance(bass, ClassificationModel)
assert bass.ClassificationName == "Bass"
# second call returns the same row
again = classification_repo.ensure_exists("Bass")
assert again.ClassificationId == bass.ClassificationId
# total rows = 4 seeded + the new “Bass”
all_rows = classification_repo.list_all()
assert len(all_rows) == 5
def test_ensure_exists_returns_existing(classification_repo):
existing = classification_repo.ensure_exists("Alto / Mezzo")
assert existing is not None
assert existing.ClassificationName == "Alto / Mezzo"
# no extra rows added
all_rows = classification_repo.list_all()
assert len(all_rows) == 4
# ----------------------------------------------------------------------
# 5⃣ Delete (optional demonstrates that the method works)
# ----------------------------------------------------------------------
def test_delete(classification_repo):
temp = classification_repo.create("TempVoice")
assert classification_repo.find_by_name("TempVoice") is not None
classification_repo.delete(temp.ClassificationId)
assert classification_repo.find_by_name("TempVoice") is None
remaining = classification_repo.list_all()
remaining_names = {r.ClassificationName for r in remaining}
assert "TempVoice" not in remaining_names
# the original four seeded names must still be present
assert {"Soprano", "Alto / Mezzo", "Tenor", "Baritone"} <= remaining_names

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

View File

@@ -0,0 +1,69 @@
# tests/test_service_availability.py
import pytest
def test_grant_and_revoke(
service_availability_repo,
member_repo,
service_type_repo,
):
"""
Verify that:
• `grant` adds a new (member, service_type) pair idempotently.
• `revoke` removes the pair.
• The helper `members_for_type` returns the expected IDs.
"""
# ------------------------------------------------------------------
# Arrange fetch the IDs we know exist from the fixture.
# ------------------------------------------------------------------
# Alice is member_id 1, Bob is member_id 2 (AUTOINCREMENT order).
alice_id = 1
bob_id = 2
# Service type IDs correspond to the order we inserted them:
# 9AM → 1, 11AM → 2, 6PM → 3
nine_am_id = 1
eleven_am_id = 2
six_pm_id = 3
# ------------------------------------------------------------------
# Act try granting a *new* availability that wasn't seeded.
# We'll give Alice the 11AM slot (she didn't have it before).
# ------------------------------------------------------------------
new_pair = service_availability_repo.grant(alice_id, eleven_am_id)
# ------------------------------------------------------------------
# Assert the row exists and the helper returns the right member list.
# ------------------------------------------------------------------
assert new_pair.MemberId == alice_id
assert new_pair.ServiceTypeId == eleven_am_id
# `members_for_type` should now contain Alice (1) **and** Bob (2) for 11AM.
members_for_11am = service_availability_repo.members_for_type(eleven_am_id)
assert set(members_for_11am) == {alice_id, bob_id}
# ------------------------------------------------------------------
# Revoke the newly added pair and ensure it disappears.
# ------------------------------------------------------------------
service_availability_repo.revoke(alice_id, eleven_am_id)
# After revocation the 11AM list should contain **only** Bob.
members_after_revoke = service_availability_repo.members_for_type(eleven_am_id)
assert members_after_revoke == [bob_id]
# Also verify that `get` returns None for the removed pair.
assert service_availability_repo.get(alice_id, eleven_am_id) is None
def test_list_by_member(service_availability_repo):
"""
Validate that `list_by_member` returns exactly the slots we seeded.
"""
# Alice (member_id 1) should have 9AM (1) and 6PM (3)
alice_slots = service_availability_repo.list_by_member(1)
alice_type_ids = sorted([s.ServiceTypeId for s in alice_slots])
assert alice_type_ids == [1, 3]
# Bob (member_id 2) should have 11AM (2) and 6PM (3)
bob_slots = service_availability_repo.list_by_member(2)
bob_type_ids = sorted([s.ServiceTypeId for s in bob_slots])
assert bob_type_ids == [2, 3]

View File

@@ -0,0 +1,62 @@
# tests/test_service_type_repo.py
import pytest
from backend.models.dataclasses import ServiceType as ServiceTypeModel
def test_create_and_find(service_type_repo):
"""
Verify that we can insert a brandnew ServiceType and retrieve it
both by primary key and by name.
"""
# Create a new slot that wasn't part of the seed data.
new_slot = service_type_repo.create("2PM")
assert isinstance(new_slot, ServiceTypeModel)
assert new_slot.TypeName == "2PM"
assert new_slot.ServiceTypeId > 0 # autoincrement worked
# Find by primary key.
fetched_by_id = service_type_repo.get_by_id(new_slot.ServiceTypeId)
assert fetched_by_id == new_slot
# Find by name.
fetched_by_name = service_type_repo.find_by_name("2PM")
assert fetched_by_name == new_slot
def test_list_all_contains_seeded_slots(service_type_repo):
"""
The three seeded slots (9AM, 11AM, 6PM) should be present and sorted
alphabetically by the repository implementation.
"""
all_slots = service_type_repo.list_all()
names = [s.TypeName for s in all_slots]
# The seed fixture inserted exactly these three names.
assert set(names) >= {"9AM", "11AM", "6PM"}
# Because ``list_all`` orders by ``TypeName ASC`` we expect alphabetical order.
assert names == sorted(names)
def test_ensure_slots_is_idempotent(service_type_repo):
"""
``ensure_slots`` should insert missing rows and return the full set,
without creating duplicates on subsequent calls.
"""
# First call inserts the three seed rows plus a brandnew one.
wanted = ["9AM", "11AM", "6PM", "3PM"]
result_first = service_type_repo.ensure_slots(wanted)
# All four names must now exist.
assert {s.TypeName for s in result_first} == set(wanted)
# Capture the IDs for later comparison.
ids_before = {s.TypeName: s.ServiceTypeId for s in result_first}
# Second call should *not* create new rows.
result_second = service_type_repo.ensure_slots(wanted)
ids_after = {s.TypeName: s.ServiceTypeId for s in result_second}
# IDs must be unchanged (no duplicates were added).
assert ids_before == ids_after
assert len(result_second) == len(wanted)