# 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="555‑1111", 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="555‑2222", 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 service‑availability tests rely on the seeded Alice/Bob rows, so we only invoke this fixture in the member‑repo 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 repository’s higher‑level 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="555‑3333", 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 # Spot‑check 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`` → far‑past). 3️⃣ ``LastScheduledAt`` ASC (same handling). The default boost window is 2 days (172 800 seconds). Additional rule (as stated in the doc‑string): *Members whose ``LastAcceptedAt`` is NULL should appear **before** members that have a non‑NULL 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 (non‑NULL 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 **3 days ago** (outside the 2‑day 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=2 days) # -------------------------------------------------------------- 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 non‑NULL 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 ISO‑8601 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