Files
nimbusflow/backend/tests/repositories/test_member.py

692 lines
24 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/repositories/test_member.py
import datetime as dt
from typing import List, Any
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
# ----------------------------------------------------------------------
# 7⃣ get_active filter active members only
# ----------------------------------------------------------------------
def test_get_active_filters_correctly(member_repo: MemberRepository, clean_members):
"""Test that get_active returns only active members."""
# Create active member
active_member = member_repo.create(
first_name="Active",
last_name="User",
email="active@example.com",
phone_number="555-1234",
classification_id=1,
is_active=1,
)
# Create inactive member
inactive_member = member_repo.create(
first_name="Inactive",
last_name="User",
email="inactive@example.com",
phone_number="555-5678",
classification_id=1,
is_active=0,
)
active_members = member_repo.get_active()
# Should only return the active member
assert len(active_members) == 1
assert active_members[0].MemberId == active_member.MemberId
assert active_members[0].FirstName == "Active"
assert active_members[0].IsActive == 1
# ----------------------------------------------------------------------
# 8⃣ set_last_accepted resets decline data and sets acceptance date
# ----------------------------------------------------------------------
def test_set_last_accepted_resets_decline_data(member_repo: MemberRepository):
"""Test that set_last_accepted clears decline data and sets acceptance timestamp."""
member = member_repo.create(
first_name="Test",
last_name="Member",
email="test@example.com",
phone_number=None,
classification_id=1,
is_active=1,
)
# First decline the member to set up decline data
yesterday_iso = (dt.date.today() - dt.timedelta(days=1)).isoformat()
member_repo.set_last_declined(member.MemberId, yesterday_iso)
# Verify decline data is set
declined_member = member_repo.get_by_id(member.MemberId)
assert declined_member.DeclineStreak == 1
assert declined_member.LastDeclinedAt == yesterday_iso
assert declined_member.LastAcceptedAt is None
# Now accept
member_repo.set_last_accepted(member.MemberId)
# Verify acceptance resets decline data
accepted_member = member_repo.get_by_id(member.MemberId)
assert accepted_member.DeclineStreak == 0
assert accepted_member.LastDeclinedAt is None
assert accepted_member.LastAcceptedAt is not None
# Verify timestamp is recent (within last 5 seconds)
accepted_time = dt.datetime.fromisoformat(accepted_member.LastAcceptedAt.replace('f', '000'))
time_diff = dt.datetime.utcnow() - accepted_time
assert time_diff.total_seconds() < 5
# ----------------------------------------------------------------------
# 9⃣ reset_to_queue_front moves member to front of scheduling queue
# ----------------------------------------------------------------------
def test_reset_to_queue_front_moves_member_to_front(member_repo: MemberRepository, clean_members):
"""Test that reset_to_queue_front properly resets timestamps to move member to front."""
# Create two members with different timestamps
older_member = make_member(
member_repo,
"Older",
"Member",
accepted_at="2025-01-01 10:00:00",
scheduled_at="2025-01-01 10:00:00",
declined_at="2025-01-01 10:00:00",
decline_streak=2,
)
newer_member = make_member(
member_repo,
"Newer",
"Member",
accepted_at="2025-08-01 10:00:00",
scheduled_at="2025-08-01 10:00:00",
)
# Verify initial queue order (newer should come first due to older accepted date)
initial_queue = member_repo.candidate_queue([1])
assert len(initial_queue) == 2
assert initial_queue[0].FirstName == "Older" # older accepted date comes first
assert initial_queue[1].FirstName == "Newer"
# Reset newer member to queue front
member_repo.reset_to_queue_front(newer_member.MemberId)
# Verify newer member is now at front
updated_queue = member_repo.candidate_queue([1])
assert len(updated_queue) == 2
assert updated_queue[0].FirstName == "Newer" # should now be first
assert updated_queue[1].FirstName == "Older"
# Verify the reset member has expected timestamp values
reset_member = member_repo.get_by_id(newer_member.MemberId)
assert reset_member.LastAcceptedAt == '1970-01-01 00:00:00'
assert reset_member.LastScheduledAt == '1970-01-01 00:00:00'
assert reset_member.LastDeclinedAt is None
assert reset_member.DeclineStreak == 0
# ----------------------------------------------------------------------
# 🔟 Edge cases and error conditions
# ----------------------------------------------------------------------
def test_create_with_minimal_data(member_repo: MemberRepository):
"""Test creating a member with only required fields."""
member = member_repo.create(
first_name="Min",
last_name="Member"
)
assert member.FirstName == "Min"
assert member.LastName == "Member"
assert member.Email is None
assert member.PhoneNumber is None
assert member.ClassificationId is None
assert member.Notes is None
assert member.IsActive == 1 # default value
assert member.DeclineStreak == 0 # default value
def test_get_by_classification_ids_empty_list(member_repo: MemberRepository):
"""Test that empty classification list returns empty result without DB query."""
result = member_repo.get_by_classification_ids([])
assert result == []
def test_get_by_classification_ids_nonexistent_classification(member_repo: MemberRepository):
"""Test querying for nonexistent classification IDs."""
result = member_repo.get_by_classification_ids([999, 1000])
assert result == []
def test_candidate_queue_with_inactive_members(member_repo: MemberRepository, clean_members):
"""Test that candidate_queue respects only_active parameter."""
# Create active and inactive members
active = make_member(member_repo, "Active", "Member", is_active=1)
inactive = make_member(member_repo, "Inactive", "Member", is_active=0)
# Test with only_active=True (default)
queue_active_only = member_repo.candidate_queue([1], only_active=True)
assert len(queue_active_only) == 1
assert queue_active_only[0].FirstName == "Active"
# Test with only_active=False
queue_all = member_repo.candidate_queue([1], only_active=False)
assert len(queue_all) == 2
names = {m.FirstName for m in queue_all}
assert names == {"Active", "Inactive"}
def test_candidate_queue_boost_window_edge_cases(member_repo: MemberRepository, clean_members):
"""Test boost logic with edge cases around the boost window."""
now = dt.datetime.utcnow()
# Member declined well outside boost window (3 days ago)
outside_window = (now - dt.timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
outside_member = make_member(
member_repo,
"Outside",
"Member",
declined_at=outside_window,
decline_streak=1,
)
# Member declined well inside boost window (6 hours ago)
well_inside = (now - dt.timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
inside_member = make_member(
member_repo,
"Inside",
"Member",
declined_at=well_inside,
decline_streak=1,
)
# Member with high decline streak (should not get boost)
high_streak_member = make_member(
member_repo,
"HighStreak",
"Member",
declined_at=well_inside,
decline_streak=5, # >= 2, so no boost
)
queue = member_repo.candidate_queue([1])
# Inside member should be boosted to front
first_names = [m.FirstName for m in queue]
assert "Inside" == first_names[0] # should be boosted
assert "HighStreak" in first_names[1:] # should not be boosted
assert "Outside" in first_names[1:] # should not be boosted
@pytest.mark.parametrize(
"decline_streak,should_boost",
[
(0, True), # streak < 2
(1, True), # streak < 2
(2, False), # streak >= 2
(5, False), # streak >= 2
]
)
def test_candidate_queue_decline_streak_boost_logic(
member_repo: MemberRepository,
clean_members,
decline_streak: int,
should_boost: bool
):
"""Test boost logic for different decline streak values."""
now = dt.datetime.utcnow()
recent_decline = (now - dt.timedelta(hours=12)).strftime("%Y-%m-%d %H:%M:%S")
# Create a baseline member (no recent declines)
baseline = make_member(member_repo, "Baseline", "Member")
# Create test member with specific decline streak
test_member = make_member(
member_repo,
"Test",
"Member",
declined_at=recent_decline,
decline_streak=decline_streak,
)
queue = member_repo.candidate_queue([1])
first_names = [m.FirstName for m in queue]
if should_boost:
assert first_names[0] == "Test" # boosted to front
assert first_names[1] == "Baseline"
else:
# Order depends on other factors, but Test should not be boosted
# Both have NULL LastAcceptedAt, so order by LastScheduledAt (both NULL)
# then likely by primary key order
assert "Test" in first_names
assert "Baseline" in first_names
def test_touch_last_scheduled_with_nonexistent_member(member_repo: MemberRepository):
"""Test touch_last_scheduled with nonexistent member ID (should not raise error)."""
# This should not raise an exception, just silently do nothing
member_repo.touch_last_scheduled(99999)
def test_set_operations_with_nonexistent_member(member_repo: MemberRepository):
"""Test set operations with nonexistent member IDs."""
# These should not raise exceptions
member_repo.set_last_accepted(99999)
member_repo.set_last_declined(99999, "2025-08-29")
member_repo.reset_to_queue_front(99999)
def test_member_data_integrity_after_operations(member_repo: MemberRepository):
"""Test that member data remains consistent after various operations."""
member = member_repo.create(
first_name="Integrity",
last_name="Test",
email="integrity@example.com",
phone_number="555-0000",
classification_id=2,
notes="Test member",
is_active=1,
)
original_id = member.MemberId
original_email = member.Email
original_classification = member.ClassificationId
# Perform various timestamp operations
member_repo.touch_last_scheduled(member.MemberId)
member_repo.set_last_declined(member.MemberId, "2025-08-29")
member_repo.set_last_accepted(member.MemberId)
member_repo.reset_to_queue_front(member.MemberId)
# Verify core data is unchanged
final_member = member_repo.get_by_id(member.MemberId)
assert final_member.MemberId == original_id
assert final_member.FirstName == "Integrity"
assert final_member.LastName == "Test"
assert final_member.Email == original_email
assert final_member.ClassificationId == original_classification
assert final_member.IsActive == 1
# Verify reset operation results
assert final_member.LastAcceptedAt == '1970-01-01 00:00:00'
assert final_member.LastScheduledAt == '1970-01-01 00:00:00'
assert final_member.LastDeclinedAt is None
assert final_member.DeclineStreak == 0