feat(backend): create improved tests

This commit is contained in:
2025-08-29 23:50:44 -04:00
parent 1dbfbb9ce6
commit b25191d99a
9 changed files with 3031 additions and 177 deletions

View File

@@ -91,16 +91,16 @@ def animate_nimbusflow_text() -> None:
"""Animate the NimbusFlow ASCII text and frame with a gold shimmer effect."""
# Complete welcome screen lines including borders
welcome_lines = [
"╔════════════════════════════════════════════════════════════════════════════════════════════",
" ",
"║ ███╗ ██╗██╗███╗ ███╗██████╗ ██╗ ██╗███████╗ ███████╗██╗ ██████╗ ██╗ ██╗ ║",
"║ ████╗ ██║██║████╗ ████║██╔══██╗██║ ██║██╔════╝ ██╔════╝██║ ██╔═══██╗██║ ██║ ║",
"║ ██╔██╗ ██║██║██╔████╔██║██████╔╝██║ ██║███████╗ █████╗ ██║ ██║ ██║██║ █╗ ██║ ║",
"║ ██║╚██╗██║██║██║╚██╔╝██║██╔══██╗██║ ██║╚════██║ ██╔══╝ ██║ ██║ ██║██║███╗██║ ║",
"║ ██║ ╚████║██║██║ ╚═╝ ██║██████╔╝╚██████╔╝███████║ ██║ ███████╗╚██████╔╝╚███╔███╔╝ ║",
"║ ╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ║",
" ",
"╚════════════════════════════════════════════════════════════════════════════════════════════"
"╔════════════════════════════════════════════════════════════════════════════════════════╗",
"║ ║",
"║ ███╗ ██╗██╗███╗ ███╗██████╗ ██╗ ██╗███████╗███████╗██╗ ██████╗ ██╗ ██╗ ║",
"║ ████╗ ██║██║████╗ ████║██╔══██╗██║ ██║██╔════╝██╔════╝██║ ██╔═══██╗██║ ██║ ║",
"║ ██╔██╗ ██║██║██╔████╔██║██████╔╝██║ ██║███████╗█████╗ ██║ ██║ ██║██║ █╗ ██║ ║",
"║ ██║╚██╗██║██║██║╚██╔╝██║██╔══██╗██║ ██║╚════██║██╔══╝ ██║ ██║ ██║██║███╗██║ ║",
"║ ██║ ╚████║██║██║ ╚═╝ ██║██████╔╝╚██████╔╝███████║██║ ███████╗╚██████╔╝╚███╔███╔╝ ║",
"║ ╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ║",
"║ ║",
"╚════════════════════════════════════════════════════════════════════════════════════════╝"
]
# Calculate max width for animation

View File

@@ -48,12 +48,17 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
scheduled_at, expires_at : datetimecompatible | None
``scheduled_at`` defaults to SQLites ``CURRENT_TIMESTAMP``.
"""
# Handle timestamp - use actual datetime if not provided
import datetime
if scheduled_at is None:
scheduled_at = datetime.datetime.now(datetime.UTC).isoformat()
schedule = ScheduleModel(
ScheduleId=-1, # placeholder will be replaced
ServiceId=service_id,
MemberId=member_id,
Status=status.value,
ScheduledAt=scheduled_at or "CURRENT_TIMESTAMP",
ScheduledAt=scheduled_at,
AcceptedAt=None,
DeclinedAt=None,
ExpiresAt=expires_at,
@@ -110,8 +115,8 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
params: list[Any] = [new_status.value]
if new_status == ScheduleStatus.DECLINED:
set_clause = "Status = ?, DeclinedAt = ?, DeclineReason = ?"
params.extend(["CURRENT_TIMESTAMP", reason])
set_clause = "Status = ?, DeclinedAt = datetime('now'), DeclineReason = ?"
params.extend([reason])
params.append(schedule_id) # WHERE clause param
@@ -148,43 +153,6 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
row = self.db.fetchone(sql, params)
return row is not None
def is_available(self, member_id: int, service_id: int) -> bool:
"""
Cooldown rule: a member is unavailable if they have accepted a
schedule for the same service within the last ``COOLDOWN_DAYS``.
"""
# Latest acceptance timestamp (if any)
sql_latest = f"""
SELECT MAX(AcceptedAt) AS last_accept
FROM {self._TABLE}
WHERE MemberId = ?
AND ServiceId = ?
AND Status = ?
"""
row = self.db.fetchone(
sql_latest,
(member_id, service_id, ScheduleStatus.ACCEPTED.value),
)
last_accept: Optional[str] = row["last_accept"] if row else None
if not last_accept:
return True # never accepted → free to schedule
COOLDOWN_DAYS = 1
sql_cooldown = f"""
SELECT 1
FROM {self._TABLE}
WHERE MemberId = ?
AND ServiceId = ?
AND Status = ?
AND DATE(AcceptedAt) >= DATE('now', '-{COOLDOWN_DAYS} day')
LIMIT 1
"""
row = self.db.fetchone(
sql_cooldown,
(member_id, service_id, ScheduleStatus.ACCEPTED.value),
)
return row is None # None → outside the cooldown window
# ------------------------------------------------------------------
# Statustransition helpers (accept / decline) kept for completeness.
@@ -194,16 +162,26 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
schedule_id: int,
accepted_at: Optional[Any] = None,
) -> None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = ?,
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
ts = accepted_at or "CURRENT_TIMESTAMP"
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, ts, schedule_id))
if accepted_at is None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = datetime('now'),
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, schedule_id))
else:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
AcceptedAt = ?,
DeclinedAt = NULL,
DeclineReason = NULL
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, accepted_at, schedule_id))
def mark_declined(
self,
@@ -211,15 +189,24 @@ class ScheduleRepository(BaseRepository[ScheduleModel]):
declined_at: Optional[Any] = None,
decline_reason: Optional[str] = None,
) -> None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = ?,
DeclineReason = ?
WHERE {self._PK} = ?
"""
ts = declined_at or "CURRENT_TIMESTAMP"
self.db.execute(sql, (ScheduleStatus.DECLINED.value, ts, decline_reason, schedule_id))
if declined_at is None:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = datetime('now'),
DeclineReason = ?
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.DECLINED.value, decline_reason, schedule_id))
else:
sql = f"""
UPDATE {self._TABLE}
SET Status = ?,
DeclinedAt = ?,
DeclineReason = ?
WHERE {self._PK} = ?
"""
self.db.execute(sql, (ScheduleStatus.DECLINED.value, declined_at, decline_reason, schedule_id))
# ------------------------------------------------------------------
# Sameday helper used by the scheduling service

View File

@@ -10,6 +10,7 @@ from backend.db import DatabaseConnection
from backend.repositories import (
MemberRepository,
ClassificationRepository,
ServiceRepository,
ServiceTypeRepository,
ServiceAvailabilityRepository,
)
@@ -156,6 +157,14 @@ def service_type_repo(
return ServiceTypeRepository(db_connection)
@pytest.fixture
def service_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ServiceRepository:
return ServiceRepository(db_connection)
@pytest.fixture
def service_availability_repo(
db_connection: DatabaseConnection,

View File

@@ -1,9 +1,10 @@
# backend/tests/repositories/test_classification.py
# ------------------------------------------------------------
# Pytest suite for the ClassificationRepository.
# Comprehensive pytest suite for the ClassificationRepository.
# ------------------------------------------------------------
import pytest
from typing import List
from backend.models import Classification as ClassificationModel
from backend.repositories import ClassificationRepository
@@ -90,4 +91,383 @@ def test_delete(classification_repo):
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
assert {"Soprano", "Alto / Mezzo", "Tenor", "Baritone"} <= remaining_names
# ----------------------------------------------------------------------
# 6⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_returns_none_when_missing(classification_repo: ClassificationRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = classification_repo.get_by_id(99999)
assert result is None
def test_get_by_id_with_negative_id(classification_repo: ClassificationRepository):
"""Test get_by_id with negative ID (should return None)."""
result = classification_repo.get_by_id(-1)
assert result is None
def test_get_by_id_with_zero_id(classification_repo: ClassificationRepository):
"""Test get_by_id with zero ID (should return None)."""
result = classification_repo.get_by_id(0)
assert result is None
def test_find_by_name_case_sensitivity(classification_repo: ClassificationRepository):
"""Test that find_by_name is case-sensitive."""
# Exact case should work
exact = classification_repo.find_by_name("Soprano")
assert exact is not None
assert exact.ClassificationName == "Soprano"
# Different cases should return None
assert classification_repo.find_by_name("soprano") is None
assert classification_repo.find_by_name("SOPRANO") is None
assert classification_repo.find_by_name("SoPrAnO") is None
def test_find_by_name_with_whitespace(classification_repo: ClassificationRepository):
"""Test find_by_name behavior with whitespace variations."""
# Exact name with spaces should work
exact = classification_repo.find_by_name("Alto / Mezzo")
assert exact is not None
# Names with extra whitespace should return None (no trimming)
assert classification_repo.find_by_name(" Alto / Mezzo") is None
assert classification_repo.find_by_name("Alto / Mezzo ") is None
assert classification_repo.find_by_name(" Alto / Mezzo ") is None
def test_find_by_name_with_empty_string(classification_repo: ClassificationRepository):
"""Test find_by_name with empty string."""
result = classification_repo.find_by_name("")
assert result is None
def test_create_with_empty_string_name(classification_repo: ClassificationRepository):
"""Test creating a classification with empty string name."""
# This should work - empty string is a valid name
empty_name = classification_repo.create("")
assert empty_name.ClassificationName == ""
assert isinstance(empty_name.ClassificationId, int)
assert empty_name.ClassificationId > 0
# Should be able to find it back
found = classification_repo.find_by_name("")
assert found is not None
assert found.ClassificationId == empty_name.ClassificationId
def test_create_with_whitespace_only_name(classification_repo: ClassificationRepository):
"""Test creating a classification with whitespace-only name."""
whitespace_name = classification_repo.create(" ")
assert whitespace_name.ClassificationName == " "
assert isinstance(whitespace_name.ClassificationId, int)
# Should be findable
found = classification_repo.find_by_name(" ")
assert found is not None
assert found.ClassificationId == whitespace_name.ClassificationId
def test_create_with_very_long_name(classification_repo: ClassificationRepository):
"""Test creating a classification with a very long name."""
long_name = "A" * 1000 # 1000 character name
long_classification = classification_repo.create(long_name)
assert long_classification.ClassificationName == long_name
assert isinstance(long_classification.ClassificationId, int)
# Should be findable
found = classification_repo.find_by_name(long_name)
assert found is not None
assert found.ClassificationId == long_classification.ClassificationId
def test_create_with_special_characters(classification_repo: ClassificationRepository):
"""Test creating classifications with special characters."""
special_names = [
"Alto/Soprano",
"Bass-Baritone",
"Counter-tenor (High)",
"Mezzo@Soprano",
"Coloratura Soprano (1st)",
"Basso Profondo & Cantante",
"Soprano (🎵)",
"Tenor - Lyric/Dramatic",
]
created_ids = []
for name in special_names:
classification = classification_repo.create(name)
assert classification.ClassificationName == name
assert isinstance(classification.ClassificationId, int)
created_ids.append(classification.ClassificationId)
# Should be findable
found = classification_repo.find_by_name(name)
assert found is not None
assert found.ClassificationId == classification.ClassificationId
# All IDs should be unique
assert len(set(created_ids)) == len(created_ids)
def test_delete_nonexistent_classification(classification_repo: ClassificationRepository):
"""Test deleting a classification that doesn't exist (should not raise error)."""
initial_count = len(classification_repo.list_all())
# This should not raise an exception
classification_repo.delete(99999)
# Count should remain the same
final_count = len(classification_repo.list_all())
assert final_count == initial_count
def test_delete_with_negative_id(classification_repo: ClassificationRepository):
"""Test delete with negative ID (should not raise error)."""
initial_count = len(classification_repo.list_all())
# This should not raise an exception
classification_repo.delete(-1)
# Count should remain the same
final_count = len(classification_repo.list_all())
assert final_count == initial_count
# ----------------------------------------------------------------------
# 7⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_list_all_ordering_consistency(classification_repo: ClassificationRepository):
"""Test that list_all always returns results in consistent alphabetical order."""
# Add some classifications with names that test alphabetical ordering
test_names = ["Zebra", "Alpha", "Beta", "Zulu", "Apple", "Banana"]
created = []
for name in test_names:
created.append(classification_repo.create(name))
# Get all classifications multiple times
for _ in range(3):
all_classifications = classification_repo.list_all()
names = [c.ClassificationName for c in all_classifications]
# Should be in alphabetical order
assert names == sorted(names)
# Should contain our test names
for name in test_names:
assert name in names
def test_ensure_exists_idempotency_stress_test(classification_repo: ClassificationRepository):
"""Test that ensure_exists is truly idempotent under multiple calls."""
name = "StressTestClassification"
# Call ensure_exists multiple times
results = []
for _ in range(10):
result = classification_repo.ensure_exists(name)
results.append(result)
# All results should be the same object (same ID)
first_id = results[0].ClassificationId
for result in results:
assert result.ClassificationId == first_id
assert result.ClassificationName == name
# Should only exist once in the database
all_classifications = classification_repo.list_all()
matching = [c for c in all_classifications if c.ClassificationName == name]
assert len(matching) == 1
def test_classification_model_data_integrity(classification_repo: ClassificationRepository):
"""Test that Classification model preserves data integrity."""
original_name = "DataIntegrityTest"
classification = classification_repo.create(original_name)
# Verify original data
assert classification.ClassificationName == original_name
original_id = classification.ClassificationId
# Retrieve and verify data is preserved
retrieved = classification_repo.get_by_id(original_id)
assert retrieved is not None
assert retrieved.ClassificationId == original_id
assert retrieved.ClassificationName == original_name
# Verify through find_by_name as well
found_by_name = classification_repo.find_by_name(original_name)
assert found_by_name is not None
assert found_by_name.ClassificationId == original_id
assert found_by_name.ClassificationName == original_name
# ----------------------------------------------------------------------
# 8⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"test_name,expected_found",
[
("Soprano", True),
("Alto / Mezzo", True),
("Tenor", True),
("Baritone", True),
("Bass", False),
("Countertenor", False),
("Mezzo-Soprano", False),
("", False),
("soprano", False), # case sensitivity
("SOPRANO", False), # case sensitivity
]
)
def test_find_by_name_comprehensive(
classification_repo: ClassificationRepository,
test_name: str,
expected_found: bool
):
"""Comprehensive test of find_by_name with various inputs."""
result = classification_repo.find_by_name(test_name)
if expected_found:
assert result is not None
assert result.ClassificationName == test_name
assert isinstance(result.ClassificationId, int)
assert result.ClassificationId > 0
else:
assert result is None
@pytest.mark.parametrize(
"test_name",
[
"NewClassification1",
"Test With Spaces",
"Special-Characters!@#",
"123NumbersFirst",
"Mixed123Characters",
"Très_French_Ñame",
"Multi\nLine\nName",
"Tab\tSeparated",
"Quote'Name",
'Double"Quote"Name',
]
)
def test_create_and_retrieve_various_names(classification_repo: ClassificationRepository, test_name: str):
"""Test creating and retrieving classifications with various name formats."""
# Create
created = classification_repo.create(test_name)
assert created.ClassificationName == test_name
assert isinstance(created.ClassificationId, int)
assert created.ClassificationId > 0
# Retrieve by ID
by_id = classification_repo.get_by_id(created.ClassificationId)
assert by_id is not None
assert by_id.ClassificationName == test_name
assert by_id.ClassificationId == created.ClassificationId
# Retrieve by name
by_name = classification_repo.find_by_name(test_name)
assert by_name is not None
assert by_name.ClassificationName == test_name
assert by_name.ClassificationId == created.ClassificationId
# ----------------------------------------------------------------------
# 9⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_classification_workflow(classification_repo: ClassificationRepository):
"""Test a complete workflow with multiple operations."""
initial_count = len(classification_repo.list_all())
# Step 1: Create a new classification
new_name = "WorkflowTest"
created = classification_repo.create(new_name)
assert created.ClassificationName == new_name
# Step 2: Verify it exists in list_all
all_classifications = classification_repo.list_all()
assert len(all_classifications) == initial_count + 1
assert new_name in [c.ClassificationName for c in all_classifications]
# Step 3: Find by name
found = classification_repo.find_by_name(new_name)
assert found is not None
assert found.ClassificationId == created.ClassificationId
# Step 4: Get by ID
by_id = classification_repo.get_by_id(created.ClassificationId)
assert by_id is not None
assert by_id.ClassificationName == new_name
# Step 5: Use ensure_exists (should return existing)
ensured = classification_repo.ensure_exists(new_name)
assert ensured.ClassificationId == created.ClassificationId
# Step 6: Delete it
classification_repo.delete(created.ClassificationId)
# Step 7: Verify it's gone
assert classification_repo.get_by_id(created.ClassificationId) is None
assert classification_repo.find_by_name(new_name) is None
final_all = classification_repo.list_all()
assert len(final_all) == initial_count
assert new_name not in [c.ClassificationName for c in final_all]
def test_multiple_classifications_with_similar_names(classification_repo: ClassificationRepository):
"""Test handling of classifications with similar but distinct names."""
base_name = "TestSimilar"
similar_names = [
base_name,
base_name + " ", # with trailing space
" " + base_name, # with leading space
base_name.upper(), # different case
base_name.lower(), # different case
base_name + "2", # with number
base_name + "_Alt", # with suffix
]
created_classifications = []
for name in similar_names:
classification = classification_repo.create(name)
created_classifications.append(classification)
assert classification.ClassificationName == name
# All should have unique IDs
ids = [c.ClassificationId for c in created_classifications]
assert len(set(ids)) == len(ids)
# All should be findable by their exact names
for i, name in enumerate(similar_names):
found = classification_repo.find_by_name(name)
assert found is not None
assert found.ClassificationId == created_classifications[i].ClassificationId
assert found.ClassificationName == name
def test_classification_repository_thread_safety_simulation(classification_repo: ClassificationRepository):
"""Simulate concurrent operations to test repository consistency."""
# This simulates what might happen if multiple threads/processes were accessing the repo
base_name = "ConcurrencyTest"
# Simulate multiple "threads" trying to ensure the same classification exists
results = []
for i in range(5):
result = classification_repo.ensure_exists(base_name)
results.append(result)
# All should return the same classification
first_id = results[0].ClassificationId
for result in results:
assert result.ClassificationId == first_id
assert result.ClassificationName == base_name
# Should only exist once in the database
all_matches = [c for c in classification_repo.list_all() if c.ClassificationName == base_name]
assert len(all_matches) == 1

View File

@@ -1,6 +1,6 @@
# tests/repositories/test_member.py
import datetime as dt
from typing import List
from typing import List, Any
import pytest
@@ -380,4 +380,313 @@ def test_set_last_declined_resets_streak_and_records_date(member_repo: MemberRep
refreshed2 = member_repo.get_by_id(member.MemberId)
assert refreshed2.DeclineStreak == 2
assert refreshed2.LastDeclinedAt == tomorrow_iso
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

View File

@@ -0,0 +1,432 @@
# tests/repositories/test_schedule.py
import datetime as dt
from typing import List
import pytest
from backend.models import Schedule as ScheduleModel, ScheduleStatus
from backend.repositories import ScheduleRepository, ServiceRepository
from backend.db import DatabaseConnection
# ----------------------------------------------------------------------
# Additional fixtures for Schedule repository testing
# ----------------------------------------------------------------------
@pytest.fixture
def schedule_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ScheduleRepository:
return ScheduleRepository(db_connection)
@pytest.fixture
def service_repo(
db_connection: DatabaseConnection,
seed_lookup_tables,
) -> ServiceRepository:
return ServiceRepository(db_connection)
@pytest.fixture
def sample_service(service_repo: ServiceRepository) -> int:
"""Create a sample service and return its ID."""
service = service_repo.create(
service_type_id=1, # 9AM from seeded data
service_date=dt.date(2025, 9, 15)
)
return service.ServiceId
@pytest.fixture
def clean_schedules(schedule_repo: ScheduleRepository):
"""Clean the Schedules table before tests."""
schedule_repo.db.execute("DELETE FROM Schedules")
schedule_repo.db._conn.commit()
# ----------------------------------------------------------------------
# Basic CRUD Operations
# ----------------------------------------------------------------------
def test_create_and_get_by_id(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test basic schedule creation and retrieval."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1, # Alice from seeded data
status=ScheduleStatus.PENDING
)
# Verify the created schedule
assert isinstance(schedule.ScheduleId, int) and schedule.ScheduleId > 0
assert schedule.ServiceId == sample_service
assert schedule.MemberId == 1
assert schedule.Status == ScheduleStatus.PENDING.value
assert schedule.ScheduledAt is not None
assert schedule.AcceptedAt is None
assert schedule.DeclinedAt is None
# Retrieve the schedule
fetched = schedule_repo.get_by_id(schedule.ScheduleId)
assert fetched is not None
assert fetched.ServiceId == sample_service
assert fetched.MemberId == 1
assert fetched.Status == ScheduleStatus.PENDING.value
def test_get_by_id_returns_none_when_missing(schedule_repo: ScheduleRepository):
"""Test that get_by_id returns None for non-existent schedules."""
assert schedule_repo.get_by_id(9999) is None
def test_create_with_decline_reason(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test creating a schedule with DECLINED status and reason."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.DECLINED,
reason="Already committed elsewhere"
)
assert schedule.Status == ScheduleStatus.DECLINED.value
assert schedule.DeclineReason == "Already committed elsewhere"
# ----------------------------------------------------------------------
# List Operations
# ----------------------------------------------------------------------
def test_list_all(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test listing all schedules."""
# Create multiple schedules
schedule1 = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
schedule2 = schedule_repo.create(
service_id=sample_service,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
schedules = schedule_repo.list_all()
assert len(schedules) == 2
schedule_ids = {s.ScheduleId for s in schedules}
assert schedule1.ScheduleId in schedule_ids
assert schedule2.ScheduleId in schedule_ids
def test_get_pending_for_service(
schedule_repo: ScheduleRepository,
service_repo: ServiceRepository,
clean_schedules
):
"""Test getting pending schedules for a specific service."""
# Create two services
service1 = service_repo.create(service_type_id=1, service_date=dt.date(2025, 9, 15))
service2 = service_repo.create(service_type_id=2, service_date=dt.date(2025, 9, 15))
# Create schedules with different statuses
pending1 = schedule_repo.create(
service_id=service1.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
accepted1 = schedule_repo.create(
service_id=service1.ServiceId,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
pending2 = schedule_repo.create(
service_id=service2.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
# Get pending schedules for service1
pending_schedules = schedule_repo.get_pending_for_service(service1.ServiceId)
assert len(pending_schedules) == 1
assert pending_schedules[0].ScheduleId == pending1.ScheduleId
assert pending_schedules[0].Status == ScheduleStatus.PENDING.value
# ----------------------------------------------------------------------
# Query Operations
# ----------------------------------------------------------------------
def test_get_one(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test getting one schedule by member and service ID."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Find it
found = schedule_repo.get_one(member_id=1, service_id=sample_service)
assert found is not None
assert found.ScheduleId == schedule.ScheduleId
# Try to find non-existent
not_found = schedule_repo.get_one(member_id=999, service_id=sample_service)
assert not_found is None
def test_has_any(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test checking if member has schedules with specific statuses."""
# Create schedules with different statuses
schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
schedule_repo.create(
service_id=sample_service,
member_id=2,
status=ScheduleStatus.ACCEPTED
)
# Test has_any
assert schedule_repo.has_any(1, sample_service, [ScheduleStatus.PENDING])
assert schedule_repo.has_any(2, sample_service, [ScheduleStatus.ACCEPTED])
assert schedule_repo.has_any(1, sample_service, [ScheduleStatus.PENDING, ScheduleStatus.ACCEPTED])
assert not schedule_repo.has_any(1, sample_service, [ScheduleStatus.DECLINED])
assert not schedule_repo.has_any(999, sample_service, [ScheduleStatus.PENDING])
# Test empty statuses list
assert not schedule_repo.has_any(1, sample_service, [])
def test_has_schedule_on_date(
schedule_repo: ScheduleRepository,
service_repo: ServiceRepository,
clean_schedules
):
"""Test checking if member has any schedule on a specific date."""
# Create services on different dates
service_today = service_repo.create(
service_type_id=1,
service_date=dt.date(2025, 9, 15)
)
service_tomorrow = service_repo.create(
service_type_id=2,
service_date=dt.date(2025, 9, 16)
)
# Create schedule for today
schedule_repo.create(
service_id=service_today.ServiceId,
member_id=1,
status=ScheduleStatus.PENDING
)
# Test has_schedule_on_date
assert schedule_repo.has_schedule_on_date(1, "2025-09-15")
assert not schedule_repo.has_schedule_on_date(1, "2025-09-16")
assert not schedule_repo.has_schedule_on_date(2, "2025-09-15")
# ----------------------------------------------------------------------
# Status Update Operations
# ----------------------------------------------------------------------
def test_update_status_to_accepted(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test updating schedule status to accepted."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Update to accepted
schedule_repo.update_status(
schedule_id=schedule.ScheduleId,
new_status=ScheduleStatus.ACCEPTED
)
# Verify the update
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.ACCEPTED.value
assert updated.DeclinedAt is None
assert updated.DeclineReason is None
def test_update_status_to_declined(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test updating schedule status to declined with reason."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Update to declined
schedule_repo.update_status(
schedule_id=schedule.ScheduleId,
new_status=ScheduleStatus.DECLINED,
reason="Family emergency"
)
# Verify the update
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.DECLINED.value
assert updated.DeclinedAt is not None
assert updated.DeclineReason == "Family emergency"
def test_mark_accepted(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test marking a schedule as accepted."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Mark as accepted
schedule_repo.mark_accepted(schedule.ScheduleId)
# Verify
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.ACCEPTED.value
assert updated.AcceptedAt is not None
assert updated.DeclinedAt is None
assert updated.DeclineReason is None
def test_mark_declined(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test marking a schedule as declined."""
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Mark as declined
schedule_repo.mark_declined(
schedule.ScheduleId,
decline_reason="Unable to attend"
)
# Verify
updated = schedule_repo.get_by_id(schedule.ScheduleId)
assert updated is not None
assert updated.Status == ScheduleStatus.DECLINED.value
assert updated.DeclinedAt is not None
assert updated.DeclineReason == "Unable to attend"
# ----------------------------------------------------------------------
# Delete Operations (Added for CLI functionality)
# ----------------------------------------------------------------------
def test_delete_schedule(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test deleting a schedule."""
# Create a schedule
schedule = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
# Verify it exists
assert schedule_repo.get_by_id(schedule.ScheduleId) is not None
# Delete it
result = schedule_repo.delete_schedule(schedule.ScheduleId)
assert result is True
# Verify it's gone
assert schedule_repo.get_by_id(schedule.ScheduleId) is None
# Try to delete again (should return False)
result2 = schedule_repo.delete_schedule(schedule.ScheduleId)
assert result2 is False
def test_delete_nonexistent_schedule(schedule_repo: ScheduleRepository):
"""Test deleting a non-existent schedule returns False."""
result = schedule_repo.delete_schedule(9999)
assert result is False
# ----------------------------------------------------------------------
# Edge Cases and Error Conditions
# ----------------------------------------------------------------------
def test_create_with_invalid_foreign_keys(
schedule_repo: ScheduleRepository,
clean_schedules
):
"""Test that creating schedule with invalid FKs raises appropriate errors."""
# This should fail due to FK constraint (assuming constraints are enforced)
with pytest.raises(Exception): # SQLite foreign key constraint error
schedule_repo.create(
service_id=9999, # Non-existent service
member_id=1,
status=ScheduleStatus.PENDING
)
def test_unique_constraint_member_service(
schedule_repo: ScheduleRepository,
sample_service: int,
clean_schedules
):
"""Test that UNIQUE constraint prevents duplicate member/service schedules."""
# Create first schedule
schedule1 = schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.PENDING
)
assert schedule1 is not None
# Attempting to create second schedule for same member/service should fail
with pytest.raises(Exception): # SQLite UNIQUE constraint error
schedule_repo.create(
service_id=sample_service,
member_id=1,
status=ScheduleStatus.DECLINED
)

View File

@@ -0,0 +1,566 @@
# backend/tests/repositories/test_service.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceRepository.
# ------------------------------------------------------------
import pytest
from datetime import date, datetime, timedelta
from typing import List
from backend.models import Service as ServiceModel
from backend.repositories import ServiceRepository
# ----------------------------------------------------------------------
# Helper fixtures for test data
# ----------------------------------------------------------------------
@pytest.fixture
def sample_dates():
"""Return a set of dates for testing."""
today = date.today()
return {
'past': today - timedelta(days=30),
'yesterday': today - timedelta(days=1),
'today': today,
'tomorrow': today + timedelta(days=1),
'next_week': today + timedelta(days=7),
'future': today + timedelta(days=30),
}
@pytest.fixture
def clean_services(service_repo: ServiceRepository):
"""Clean the Services table for tests that need isolation."""
# Clear any existing services to start fresh
service_repo.db.execute(f"DELETE FROM {service_repo._TABLE}")
service_repo.db._conn.commit()
# ----------------------------------------------------------------------
# 1<0F> Basic CRUD  create & get_by_id
# ----------------------------------------------------------------------
def test_create_and_get_by_id(service_repo: ServiceRepository, sample_dates):
"""Test basic service creation and retrieval by ID."""
service = service_repo.create(
service_type_id=1,
service_date=sample_dates['tomorrow']
)
# Verify creation
assert isinstance(service.ServiceId, int) and service.ServiceId > 0
assert service.ServiceTypeId == 1
assert service.ServiceDate == sample_dates['tomorrow']
# Retrieve the same service
fetched = service_repo.get_by_id(service.ServiceId)
assert fetched is not None
assert fetched.ServiceId == service.ServiceId
assert fetched.ServiceTypeId == 1
assert fetched.ServiceDate == sample_dates['tomorrow']
def test_get_by_id_returns_none_when_missing(service_repo: ServiceRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = service_repo.get_by_id(99999)
assert result is None
def test_create_with_date_object(service_repo: ServiceRepository):
"""Test creating service with a date object."""
test_date = date(2025, 12, 25)
service = service_repo.create(service_type_id=2, service_date=test_date)
assert service.ServiceDate == test_date
assert service.ServiceTypeId == 2
# ----------------------------------------------------------------------
# 2<0F> list_all  bulk operations
# ----------------------------------------------------------------------
def test_list_all(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test listing all services."""
# Create multiple services
services_data = [
(1, sample_dates['today']),
(2, sample_dates['tomorrow']),
(1, sample_dates['next_week']),
]
created_services = []
for service_type_id, service_date in services_data:
service = service_repo.create(service_type_id, service_date)
created_services.append(service)
all_services = service_repo.list_all()
assert len(all_services) == 3
# Verify all created services are in the list
service_ids = {s.ServiceId for s in all_services}
expected_ids = {s.ServiceId for s in created_services}
assert service_ids == expected_ids
def test_list_all_empty_table(service_repo: ServiceRepository, clean_services):
"""Test list_all when table is empty."""
all_services = service_repo.list_all()
assert all_services == []
# ----------------------------------------------------------------------
# 3<0F> upcoming  date-based filtering
# ----------------------------------------------------------------------
def test_upcoming_default_behavior(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with default parameters (from today, limit 100)."""
# Create services on various dates
past_service = service_repo.create(1, sample_dates['past'])
today_service = service_repo.create(1, sample_dates['today'])
future_service = service_repo.create(1, sample_dates['future'])
upcoming = service_repo.upcoming()
# Should include today and future, but not past
upcoming_ids = {s.ServiceId for s in upcoming}
assert today_service.ServiceId in upcoming_ids
assert future_service.ServiceId in upcoming_ids
assert past_service.ServiceId not in upcoming_ids
def test_upcoming_with_specific_after_date(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with a specific after date."""
# Create services
service_repo.create(1, sample_dates['yesterday'])
tomorrow_service = service_repo.create(1, sample_dates['tomorrow'])
future_service = service_repo.create(1, sample_dates['future'])
# Get services from tomorrow onwards
upcoming = service_repo.upcoming(after=sample_dates['tomorrow'])
upcoming_ids = {s.ServiceId for s in upcoming}
assert tomorrow_service.ServiceId in upcoming_ids
assert future_service.ServiceId in upcoming_ids
assert len(upcoming) == 2
def test_upcoming_with_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with a limit parameter."""
# Create multiple future services
for i in range(5):
service_repo.create(1, sample_dates['today'] + timedelta(days=i))
upcoming = service_repo.upcoming(limit=3)
assert len(upcoming) == 3
def test_upcoming_chronological_order(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test that upcoming() returns services in chronological order."""
# Create services in non-chronological order
service_dates = [
sample_dates['future'],
sample_dates['tomorrow'],
sample_dates['next_week'],
sample_dates['today'],
]
for service_date in service_dates:
service_repo.create(1, service_date)
upcoming = service_repo.upcoming()
# Verify chronological order
for i in range(len(upcoming) - 1):
assert upcoming[i].ServiceDate <= upcoming[i + 1].ServiceDate
def test_upcoming_no_results(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() when no future services exist."""
# Create only past services
service_repo.create(1, sample_dates['past'])
service_repo.create(1, sample_dates['yesterday'])
upcoming = service_repo.upcoming()
assert upcoming == []
# ----------------------------------------------------------------------
# 4<0F> by_type  service type filtering
# ----------------------------------------------------------------------
def test_by_type_single_type(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test filtering by a single service type."""
# Create services of different types
type1_service = service_repo.create(1, sample_dates['today'])
type2_service = service_repo.create(2, sample_dates['tomorrow'])
type1_service2 = service_repo.create(1, sample_dates['future'])
type1_services = service_repo.by_type([1])
# Should only include type 1 services
type1_ids = {s.ServiceId for s in type1_services}
assert type1_service.ServiceId in type1_ids
assert type1_service2.ServiceId in type1_ids
assert type2_service.ServiceId not in type1_ids
assert len(type1_services) == 2
def test_by_type_multiple_types(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test filtering by multiple service types."""
# Create services of different types
type1_service = service_repo.create(1, sample_dates['today'])
type2_service = service_repo.create(2, sample_dates['tomorrow'])
type3_service = service_repo.create(3, sample_dates['future'])
multi_type_services = service_repo.by_type([1, 3])
# Should include type 1 and 3, but not type 2
multi_ids = {s.ServiceId for s in multi_type_services}
assert type1_service.ServiceId in multi_ids
assert type3_service.ServiceId in multi_ids
assert type2_service.ServiceId not in multi_ids
assert len(multi_type_services) == 2
def test_by_type_empty_list(service_repo: ServiceRepository):
"""Test by_type() with empty type list returns empty result without DB query."""
result = service_repo.by_type([])
assert result == []
def test_by_type_nonexistent_types(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test by_type() with nonexistent service types."""
service_repo.create(1, sample_dates['today'])
result = service_repo.by_type([999, 1000])
assert result == []
def test_by_type_chronological_order(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test that by_type() returns services in chronological order."""
# Create services of same type in non-chronological order
service_dates = [
sample_dates['future'],
sample_dates['today'],
sample_dates['tomorrow'],
]
for service_date in service_dates:
service_repo.create(1, service_date)
services = service_repo.by_type([1])
# Verify chronological order
for i in range(len(services) - 1):
assert services[i].ServiceDate <= services[i + 1].ServiceDate
# ----------------------------------------------------------------------
# 5<0F> reschedule  update operations
# ----------------------------------------------------------------------
def test_reschedule_service(service_repo: ServiceRepository, sample_dates):
"""Test rescheduling a service to a new date."""
original_date = sample_dates['today']
new_date = sample_dates['future']
service = service_repo.create(1, original_date)
assert service.ServiceDate == original_date
# Reschedule the service
service_repo.reschedule(service.ServiceId, new_date)
# Verify the change
updated_service = service_repo.get_by_id(service.ServiceId)
assert updated_service is not None
assert updated_service.ServiceDate == new_date
assert updated_service.ServiceTypeId == 1 # Should remain unchanged
def test_reschedule_nonexistent_service(service_repo: ServiceRepository, sample_dates):
"""Test rescheduling a nonexistent service (should not raise error)."""
# This should not raise an exception
service_repo.reschedule(99999, sample_dates['tomorrow'])
# ----------------------------------------------------------------------
# 6<0F> Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_with_negative_id(service_repo: ServiceRepository):
"""Test get_by_id with negative ID (should return None)."""
result = service_repo.get_by_id(-1)
assert result is None
def test_get_by_id_with_zero_id(service_repo: ServiceRepository):
"""Test get_by_id with zero ID (should return None)."""
result = service_repo.get_by_id(0)
assert result is None
def test_create_with_invalid_service_type_id_raises_error(service_repo: ServiceRepository, sample_dates):
"""Test creating service with invalid service type ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_repo.create(999, sample_dates['today'])
def test_create_with_very_old_date(service_repo: ServiceRepository):
"""Test creating service with a very old date."""
very_old_date = date(1900, 1, 1)
service = service_repo.create(1, very_old_date)
assert service.ServiceDate == very_old_date
def test_create_with_far_future_date(service_repo: ServiceRepository):
"""Test creating service with a far future date."""
far_future_date = date(2100, 12, 31)
service = service_repo.create(1, far_future_date)
assert service.ServiceDate == far_future_date
def test_upcoming_with_zero_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with zero limit."""
service_repo.create(1, sample_dates['tomorrow'])
upcoming = service_repo.upcoming(limit=0)
assert upcoming == []
def test_upcoming_with_negative_limit(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test upcoming() with negative limit (SQLite behavior)."""
service_repo.create(1, sample_dates['tomorrow'])
# SQLite treats negative LIMIT as unlimited
upcoming = service_repo.upcoming(limit=-1)
assert len(upcoming) >= 0 # Should not crash
def test_by_type_with_duplicate_type_ids(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test by_type() with duplicate type IDs in the list."""
service1 = service_repo.create(1, sample_dates['today'])
service2 = service_repo.create(2, sample_dates['tomorrow'])
# Pass duplicate type IDs
services = service_repo.by_type([1, 1, 2, 1])
# Should return services of both types (no duplicates in result)
service_ids = {s.ServiceId for s in services}
assert service1.ServiceId in service_ids
assert service2.ServiceId in service_ids
assert len(services) == 2
# ----------------------------------------------------------------------
# 7<0F> Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_service_model_data_integrity(service_repo: ServiceRepository, sample_dates):
"""Test that Service model preserves data integrity."""
original_type_id = 2
original_date = sample_dates['tomorrow']
service = service_repo.create(original_type_id, original_date)
original_id = service.ServiceId
# Retrieve and verify data is preserved
retrieved = service_repo.get_by_id(original_id)
assert retrieved is not None
assert retrieved.ServiceId == original_id
assert retrieved.ServiceTypeId == original_type_id
assert retrieved.ServiceDate == original_date
# Verify through list_all as well
all_services = service_repo.list_all()
matching_services = [s for s in all_services if s.ServiceId == original_id]
assert len(matching_services) == 1
assert matching_services[0].ServiceTypeId == original_type_id
assert matching_services[0].ServiceDate == original_date
def test_multiple_services_same_date_and_type(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test creating multiple services on same date and type."""
# Should be allowed - multiple services can exist for same date/type
service1 = service_repo.create(1, sample_dates['today'])
service2 = service_repo.create(1, sample_dates['today'])
assert service1.ServiceId != service2.ServiceId
assert service1.ServiceTypeId == service2.ServiceTypeId
assert service1.ServiceDate == service2.ServiceDate
# Both should be findable
assert service_repo.get_by_id(service1.ServiceId) is not None
assert service_repo.get_by_id(service2.ServiceId) is not None
# ----------------------------------------------------------------------
# 8<0F> Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("service_type_id", [1, 2, 3])
def test_create_with_valid_service_type_ids(service_repo: ServiceRepository, service_type_id):
"""Test creating services with valid service type IDs."""
test_date = date.today()
service = service_repo.create(service_type_id, test_date)
assert service.ServiceTypeId == service_type_id
assert service.ServiceDate == test_date
assert isinstance(service.ServiceId, int)
assert service.ServiceId > 0
@pytest.mark.parametrize("invalid_service_type_id", [999, -1, 0])
def test_create_with_invalid_service_type_ids_raises_error(service_repo: ServiceRepository, invalid_service_type_id):
"""Test creating services with invalid service type IDs raises foreign key errors."""
test_date = date.today()
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_repo.create(invalid_service_type_id, test_date)
@pytest.mark.parametrize(
"days_offset,should_be_included",
[
(-30, False), # Past
(-1, False), # Yesterday
(0, True), # Today
(1, True), # Tomorrow
(7, True), # Next week
(30, True), # Future
]
)
def test_upcoming_date_filtering(
service_repo: ServiceRepository,
clean_services,
days_offset: int,
should_be_included: bool
):
"""Test upcoming() date filtering logic."""
test_date = date.today() + timedelta(days=days_offset)
service = service_repo.create(1, test_date)
upcoming = service_repo.upcoming()
upcoming_ids = {s.ServiceId for s in upcoming}
if should_be_included:
assert service.ServiceId in upcoming_ids
else:
assert service.ServiceId not in upcoming_ids
@pytest.mark.parametrize("limit", [1, 5, 10, 50, 100])
def test_upcoming_limit_parameter(service_repo: ServiceRepository, clean_services, limit: int):
"""Test upcoming() with various limit values."""
# Create more services than the limit
for i in range(limit + 5):
service_repo.create(1, date.today() + timedelta(days=i))
upcoming = service_repo.upcoming(limit=limit)
assert len(upcoming) == limit
# ----------------------------------------------------------------------
# 9<0F> Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_service_workflow(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test a complete workflow with multiple operations."""
initial_count = len(service_repo.list_all())
# Step 1: Create a new service
original_date = sample_dates['tomorrow']
service = service_repo.create(2, original_date)
assert service.ServiceTypeId == 2
assert service.ServiceDate == original_date
# Step 2: Verify it exists in list_all
all_services = service_repo.list_all()
assert len(all_services) == initial_count + 1
service_ids = {s.ServiceId for s in all_services}
assert service.ServiceId in service_ids
# Step 3: Find it in upcoming services
upcoming = service_repo.upcoming()
upcoming_ids = {s.ServiceId for s in upcoming}
assert service.ServiceId in upcoming_ids
# Step 4: Find it by type
by_type = service_repo.by_type([2])
by_type_ids = {s.ServiceId for s in by_type}
assert service.ServiceId in by_type_ids
# Step 5: Reschedule it
new_date = sample_dates['future']
service_repo.reschedule(service.ServiceId, new_date)
# Step 6: Verify the reschedule
updated_service = service_repo.get_by_id(service.ServiceId)
assert updated_service is not None
assert updated_service.ServiceDate == new_date
assert updated_service.ServiceTypeId == 2
def test_complex_date_filtering_scenario(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test complex scenarios with multiple date filters."""
# Create services across different dates and types
services_data = [
(1, sample_dates['past']), # Should not appear in upcoming
(1, sample_dates['today']), # Should appear in upcoming
(2, sample_dates['tomorrow']), # Should appear in upcoming
(1, sample_dates['future']), # Should appear in upcoming
(3, sample_dates['next_week']), # Should appear in upcoming
]
created_services = {}
for service_type_id, service_date in services_data:
service = service_repo.create(service_type_id, service_date)
created_services[(service_type_id, service_date)] = service
# Test upcoming from tomorrow
upcoming_from_tomorrow = service_repo.upcoming(after=sample_dates['tomorrow'])
upcoming_dates = {s.ServiceDate for s in upcoming_from_tomorrow}
assert sample_dates['tomorrow'] in upcoming_dates
assert sample_dates['future'] in upcoming_dates
assert sample_dates['next_week'] in upcoming_dates
assert sample_dates['past'] not in upcoming_dates
assert sample_dates['today'] not in upcoming_dates
# Test by specific types
type1_services = service_repo.by_type([1])
type1_dates = {s.ServiceDate for s in type1_services}
assert sample_dates['past'] in type1_dates
assert sample_dates['today'] in type1_dates
assert sample_dates['future'] in type1_dates
# Test combination: upcoming type 1 services (filter by date intersection)
upcoming_all = service_repo.upcoming()
upcoming_type1 = [s for s in upcoming_all if s.ServiceTypeId == 1]
upcoming_type1_dates = {s.ServiceDate for s in upcoming_type1}
assert sample_dates['today'] in upcoming_type1_dates
assert sample_dates['future'] in upcoming_type1_dates
assert sample_dates['past'] not in upcoming_type1_dates
def test_service_repository_consistency(service_repo: ServiceRepository, sample_dates, clean_services):
"""Test repository consistency across different query methods."""
# Create a mix of services
test_services = []
for i in range(5):
service = service_repo.create(
service_type_id=(i % 3) + 1,
service_date=sample_dates['today'] + timedelta(days=i)
)
test_services.append(service)
# All services should be found through list_all
all_services = service_repo.list_all()
all_ids = {s.ServiceId for s in all_services}
test_ids = {s.ServiceId for s in test_services}
assert test_ids.issubset(all_ids)
# Each service should be retrievable individually
for service in test_services:
retrieved = service_repo.get_by_id(service.ServiceId)
assert retrieved is not None
assert retrieved.ServiceId == service.ServiceId
assert retrieved.ServiceTypeId == service.ServiceTypeId
assert retrieved.ServiceDate == service.ServiceDate
# Services should appear in appropriate filtered queries
future_services = service_repo.upcoming(after=sample_dates['tomorrow'])
future_ids = {s.ServiceId for s in future_services}
for service in test_services:
if service.ServiceDate >= sample_dates['tomorrow']:
assert service.ServiceId in future_ids
else:
assert service.ServiceId not in future_ids

View File

@@ -1,69 +1,652 @@
# tests/test_service_availability.py
# backend/tests/repositories/test_service_availability.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceAvailabilityRepository.
# ------------------------------------------------------------
import pytest
from typing import List
from backend.models import ServiceAvailability as ServiceAvailabilityModel
from backend.repositories import ServiceAvailabilityRepository
def test_grant_and_revoke(
service_availability_repo,
member_repo,
service_type_repo,
# ----------------------------------------------------------------------
# Helper fixtures for test data
# ----------------------------------------------------------------------
@pytest.fixture
def clean_service_availability(service_availability_repo: ServiceAvailabilityRepository):
"""Clean the ServiceAvailability table for tests that need isolation."""
# Clear any existing service availability records to start fresh
service_availability_repo.db.execute(f"DELETE FROM {service_availability_repo._TABLE}")
service_availability_repo.db._conn.commit()
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create, get, delete
# ----------------------------------------------------------------------
def test_create_and_get(service_availability_repo: ServiceAvailabilityRepository):
"""Test basic service availability creation and retrieval."""
# Create a new availability record
availability = service_availability_repo.create(member_id=1, service_type_id=1)
# Verify creation
assert isinstance(availability.ServiceAvailabilityId, int)
assert availability.ServiceAvailabilityId > 0
assert availability.MemberId == 1
assert availability.ServiceTypeId == 1
# Retrieve the same record
fetched = service_availability_repo.get(member_id=1, service_type_id=1)
assert fetched is not None
assert fetched.ServiceAvailabilityId == availability.ServiceAvailabilityId
assert fetched.MemberId == 1
assert fetched.ServiceTypeId == 1
def test_get_returns_none_when_missing(service_availability_repo: ServiceAvailabilityRepository):
"""Test that get returns None for nonexistent member/service type pairs."""
result = service_availability_repo.get(member_id=999, service_type_id=999)
assert result is None
def test_create_is_idempotent(service_availability_repo: ServiceAvailabilityRepository):
"""Test that create returns existing record if pair already exists."""
# Create first record
first = service_availability_repo.create(member_id=1, service_type_id=1)
# Create again with same parameters - should return existing record
second = service_availability_repo.create(member_id=1, service_type_id=1)
# Should be the same record
assert first.ServiceAvailabilityId == second.ServiceAvailabilityId
assert first.MemberId == second.MemberId
assert first.ServiceTypeId == second.ServiceTypeId
def test_delete_by_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test deleting availability record by primary key."""
# Create a record
availability = service_availability_repo.create(member_id=1, service_type_id=1)
original_id = availability.ServiceAvailabilityId
# Verify it exists
assert service_availability_repo.get(member_id=1, service_type_id=1) is not None
# Delete it
service_availability_repo.delete(original_id)
# Verify it's gone
assert service_availability_repo.get(member_id=1, service_type_id=1) is None
def test_delete_nonexistent_record(service_availability_repo: ServiceAvailabilityRepository):
"""Test deleting a nonexistent record (should not raise error)."""
# This should not raise an exception
service_availability_repo.delete(99999)
# ----------------------------------------------------------------------
# 2⃣ Grant and revoke operations
# ----------------------------------------------------------------------
def test_grant_and_revoke(service_availability_repo: ServiceAvailabilityRepository):
"""Test the grant and revoke convenience methods."""
# Grant access
granted = service_availability_repo.grant(member_id=1, service_type_id=1)
assert granted.MemberId == 1
assert granted.ServiceTypeId == 1
# Verify it was granted
fetched = service_availability_repo.get(member_id=1, service_type_id=1)
assert fetched is not None
assert fetched.ServiceAvailabilityId == granted.ServiceAvailabilityId
# Revoke access
service_availability_repo.revoke(member_id=1, service_type_id=1)
# Verify it was revoked
assert service_availability_repo.get(member_id=1, service_type_id=1) is None
def test_grant_is_idempotent(service_availability_repo: ServiceAvailabilityRepository):
"""Test that grant is idempotent (multiple calls return same record)."""
# Grant access twice
first_grant = service_availability_repo.grant(member_id=1, service_type_id=1)
second_grant = service_availability_repo.grant(member_id=1, service_type_id=1)
# Should return the same record
assert first_grant.ServiceAvailabilityId == second_grant.ServiceAvailabilityId
assert first_grant.MemberId == second_grant.MemberId
assert first_grant.ServiceTypeId == second_grant.ServiceTypeId
def test_revoke_nonexistent_record(service_availability_repo: ServiceAvailabilityRepository):
"""Test revoking a nonexistent member/service type pair (should not raise error)."""
# This should not raise an exception
service_availability_repo.revoke(member_id=999, service_type_id=999)
# ----------------------------------------------------------------------
# 3⃣ List operations
# ----------------------------------------------------------------------
def test_list_by_member(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all availabilities for a specific member."""
member_id = 1
service_types = [1, 2, 3]
# Grant access to multiple service types
created_records = []
for service_type_id in service_types:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all availabilities for the member
member_availabilities = service_availability_repo.list_by_member(member_id)
# Should have all the records we created
assert len(member_availabilities) == 3
member_service_types = {a.ServiceTypeId for a in member_availabilities}
assert member_service_types == set(service_types)
# All should belong to the same member
for availability in member_availabilities:
assert availability.MemberId == member_id
def test_list_by_member_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test listing availabilities for a member with no records."""
availabilities = service_availability_repo.list_by_member(member_id=999)
assert availabilities == []
def test_list_by_service_type(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all members available for a specific service type."""
service_type_id = 1
member_ids = [1, 2]
# Grant access to multiple members
created_records = []
for member_id in member_ids:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all availabilities for the service type
type_availabilities = service_availability_repo.list_by_service_type(service_type_id)
# Should have all the records we created
assert len(type_availabilities) == 2
available_members = {a.MemberId for a in type_availabilities}
assert available_members == set(member_ids)
# All should be for the same service type
for availability in type_availabilities:
assert availability.ServiceTypeId == service_type_id
def test_list_by_service_type_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test listing availabilities for a service type with no records."""
availabilities = service_availability_repo.list_by_service_type(service_type_id=999)
assert availabilities == []
def test_list_all(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test listing all service availability records."""
# Create multiple records
test_data = [
(1, 1), (1, 2), (1, 3), # Member 1 available for types 1,2,3
(2, 1), (2, 2), # Member 2 available for types 1,2
]
created_records = []
for member_id, service_type_id in test_data:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# List all records
all_records = service_availability_repo.list_all()
# Should have all the records we created
assert len(all_records) == len(test_data)
# Verify all our records are present
created_ids = {r.ServiceAvailabilityId for r in created_records}
fetched_ids = {r.ServiceAvailabilityId for r in all_records}
assert created_ids.issubset(fetched_ids)
def test_list_all_empty_table(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test list_all when table is empty."""
all_records = service_availability_repo.list_all()
assert all_records == []
# ----------------------------------------------------------------------
# 4⃣ members_for_type helper method
# ----------------------------------------------------------------------
def test_members_for_type(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test the members_for_type helper method."""
service_type_id = 1
member_ids = [1, 2] # Valid member IDs only
# Grant access to multiple members
for member_id in member_ids:
service_availability_repo.grant(member_id, service_type_id)
# Get member IDs for the service type
available_members = service_availability_repo.members_for_type(service_type_id)
# Should return the member IDs we granted access to
assert set(available_members) == set(member_ids)
# Should return integers (member IDs)
for member_id in available_members:
assert isinstance(member_id, int)
def test_members_for_type_empty(service_availability_repo: ServiceAvailabilityRepository):
"""Test members_for_type with no available members."""
member_ids = service_availability_repo.members_for_type(service_type_id=999)
assert member_ids == []
def test_members_for_type_ordering(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test that members_for_type returns consistent ordering."""
service_type_id = 1
member_ids = [2, 1] # Create in non-sequential order
# Grant access in the specified order
for member_id in member_ids:
service_availability_repo.grant(member_id, service_type_id)
# Get member IDs multiple times
results = []
for _ in range(3):
available_members = service_availability_repo.members_for_type(service_type_id)
results.append(available_members)
# All results should be identical (consistent ordering)
for i in range(1, len(results)):
assert results[0] == results[i]
# Should contain all our member IDs
assert set(results[0]) == set(member_ids)
# ----------------------------------------------------------------------
# 5⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_create_with_invalid_member_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with invalid member ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(member_id=999, service_type_id=1)
def test_create_with_invalid_service_type_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with invalid service type ID raises foreign key error."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(member_id=1, service_type_id=999)
def test_create_with_negative_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with negative IDs raises foreign key error."""
with pytest.raises(Exception):
service_availability_repo.create(member_id=-1, service_type_id=1)
with pytest.raises(Exception):
service_availability_repo.create(member_id=1, service_type_id=-1)
def test_create_with_zero_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test creating availability with zero IDs raises foreign key error."""
with pytest.raises(Exception):
service_availability_repo.create(member_id=0, service_type_id=1)
with pytest.raises(Exception):
service_availability_repo.create(member_id=1, service_type_id=0)
def test_get_with_negative_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test get with negative IDs returns None."""
assert service_availability_repo.get(member_id=-1, service_type_id=1) is None
assert service_availability_repo.get(member_id=1, service_type_id=-1) is None
def test_get_with_zero_ids(service_availability_repo: ServiceAvailabilityRepository):
"""Test get with zero IDs returns None."""
assert service_availability_repo.get(member_id=0, service_type_id=1) is None
assert service_availability_repo.get(member_id=1, service_type_id=0) is None
def test_delete_with_negative_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test delete with negative ID (should not raise error)."""
service_availability_repo.delete(-1)
def test_delete_with_zero_id(service_availability_repo: ServiceAvailabilityRepository):
"""Test delete with zero ID (should not raise error)."""
service_availability_repo.delete(0)
# ----------------------------------------------------------------------
# 6⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_unique_constraint_enforcement(service_availability_repo: ServiceAvailabilityRepository):
"""Test that the unique constraint on (MemberId, ServiceTypeId) is enforced."""
# Create first record
first = service_availability_repo.create(member_id=1, service_type_id=1)
# Try to create duplicate - should return existing record due to idempotent behavior
second = service_availability_repo.create(member_id=1, service_type_id=1)
# Should be the same record (idempotent behavior)
assert first.ServiceAvailabilityId == second.ServiceAvailabilityId
# Verify only one record exists
all_records = service_availability_repo.list_all()
matching_records = [r for r in all_records if r.MemberId == 1 and r.ServiceTypeId == 1]
assert len(matching_records) == 1
def test_service_availability_model_data_integrity(service_availability_repo: ServiceAvailabilityRepository):
"""Test that ServiceAvailability model preserves data integrity."""
original_member_id = 1
original_service_type_id = 2
availability = service_availability_repo.create(original_member_id, original_service_type_id)
original_id = availability.ServiceAvailabilityId
# Retrieve and verify data is preserved
retrieved = service_availability_repo.get(original_member_id, original_service_type_id)
assert retrieved is not None
assert retrieved.ServiceAvailabilityId == original_id
assert retrieved.MemberId == original_member_id
assert retrieved.ServiceTypeId == original_service_type_id
# Verify through list operations as well
by_member = service_availability_repo.list_by_member(original_member_id)
matching_by_member = [r for r in by_member if r.ServiceTypeId == original_service_type_id]
assert len(matching_by_member) == 1
assert matching_by_member[0].ServiceAvailabilityId == original_id
by_type = service_availability_repo.list_by_service_type(original_service_type_id)
matching_by_type = [r for r in by_type if r.MemberId == original_member_id]
assert len(matching_by_type) == 1
assert matching_by_type[0].ServiceAvailabilityId == original_id
def test_cross_method_consistency(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test consistency across different query methods."""
# Create a complex availability matrix
test_data = [
(1, 1), (1, 2), # Member 1: types 1,2
(2, 1), (2, 3), # Member 2: types 1,3
]
created_records = []
for member_id, service_type_id in test_data:
record = service_availability_repo.grant(member_id, service_type_id)
created_records.append(record)
# Verify consistency across methods
all_records = service_availability_repo.list_all()
# Check each member's records via list_by_member
for member_id in [1, 2]:
member_records = service_availability_repo.list_by_member(member_id)
member_records_from_all = [r for r in all_records if r.MemberId == member_id]
assert len(member_records) == len(member_records_from_all)
member_ids_direct = {r.ServiceAvailabilityId for r in member_records}
member_ids_from_all = {r.ServiceAvailabilityId for r in member_records_from_all}
assert member_ids_direct == member_ids_from_all
# Check each service type's records via list_by_service_type
for service_type_id in [1, 2, 3]:
type_records = service_availability_repo.list_by_service_type(service_type_id)
type_records_from_all = [r for r in all_records if r.ServiceTypeId == service_type_id]
assert len(type_records) == len(type_records_from_all)
type_ids_direct = {r.ServiceAvailabilityId for r in type_records}
type_ids_from_all = {r.ServiceAvailabilityId for r in type_records_from_all}
assert type_ids_direct == type_ids_from_all
# Verify members_for_type consistency
member_ids = service_availability_repo.members_for_type(service_type_id)
member_ids_from_records = [r.MemberId for r in type_records]
assert set(member_ids) == set(member_ids_from_records)
# ----------------------------------------------------------------------
# 7⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("member_id,service_type_id", [
(1, 1), (1, 2), (1, 3),
(2, 1), (2, 2), (2, 3),
])
def test_create_and_retrieve_valid_combinations(
service_availability_repo: ServiceAvailabilityRepository,
member_id: int,
service_type_id: int
):
"""
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
"""Test creating and retrieving various valid member/service type combinations."""
# Create
created = service_availability_repo.create(member_id, service_type_id)
assert created.MemberId == member_id
assert created.ServiceTypeId == service_type_id
assert isinstance(created.ServiceAvailabilityId, int)
assert created.ServiceAvailabilityId > 0
# Retrieve
retrieved = service_availability_repo.get(member_id, service_type_id)
assert retrieved is not None
assert retrieved.ServiceAvailabilityId == created.ServiceAvailabilityId
assert retrieved.MemberId == member_id
assert retrieved.ServiceTypeId == service_type_id
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]
@pytest.mark.parametrize("invalid_member_id,invalid_service_type_id", [
(999, 1), (1, 999), (999, 999),
(-1, 1), (1, -1), (-1, -1),
(0, 1), (1, 0), (0, 0),
])
def test_create_with_invalid_combinations_raises_error(
service_availability_repo: ServiceAvailabilityRepository,
invalid_member_id: int,
invalid_service_type_id: int
):
"""Test creating availability with invalid combinations raises foreign key errors."""
with pytest.raises(Exception): # SQLite IntegrityError for FK constraint
service_availability_repo.create(invalid_member_id, invalid_service_type_id)
# 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]
@pytest.mark.parametrize("member_id", [1, 2])
def test_list_by_member_various_members(service_availability_repo: ServiceAvailabilityRepository, member_id: int):
"""Test list_by_member with various member IDs."""
# Grant access to a service type
service_availability_repo.grant(member_id, service_type_id=1)
# List availabilities
availabilities = service_availability_repo.list_by_member(member_id)
# Should have at least one record (the one we just granted)
assert len(availabilities) >= 1
# All records should belong to the specified member
for availability in availabilities:
assert availability.MemberId == member_id
@pytest.mark.parametrize("service_type_id", [1, 2, 3])
def test_list_by_service_type_various_types(service_availability_repo: ServiceAvailabilityRepository, service_type_id: int):
"""Test list_by_service_type with various service type IDs."""
# Grant access to a member
service_availability_repo.grant(member_id=1, service_type_id=service_type_id)
# List availabilities
availabilities = service_availability_repo.list_by_service_type(service_type_id)
# Should have at least one record (the one we just granted)
assert len(availabilities) >= 1
# All records should be for the specified service type
for availability in availabilities:
assert availability.ServiceTypeId == service_type_id
# ----------------------------------------------------------------------
# 8⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_availability_workflow(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test a complete workflow with multiple operations."""
member_id = 1
service_type_ids = [1, 2, 3]
initial_count = len(service_availability_repo.list_all())
# Step 1: Grant access to multiple service types
granted_records = []
for service_type_id in service_type_ids:
record = service_availability_repo.grant(member_id, service_type_id)
granted_records.append(record)
assert record.MemberId == member_id
assert record.ServiceTypeId == service_type_id
# Step 2: Verify records exist in list_all
all_records = service_availability_repo.list_all()
assert len(all_records) == initial_count + 3
granted_ids = {r.ServiceAvailabilityId for r in granted_records}
all_ids = {r.ServiceAvailabilityId for r in all_records}
assert granted_ids.issubset(all_ids)
# Step 3: Verify via list_by_member
member_records = service_availability_repo.list_by_member(member_id)
member_service_types = {r.ServiceTypeId for r in member_records}
assert set(service_type_ids) == member_service_types
# Step 4: Verify via list_by_service_type and members_for_type
for service_type_id in service_type_ids:
type_records = service_availability_repo.list_by_service_type(service_type_id)
type_member_ids = {r.MemberId for r in type_records}
assert member_id in type_member_ids
member_ids_for_type = service_availability_repo.members_for_type(service_type_id)
assert member_id in member_ids_for_type
# Step 5: Revoke access to one service type
revoked_type = service_type_ids[1] # Revoke access to type 2
service_availability_repo.revoke(member_id, revoked_type)
# Step 6: Verify revocation
assert service_availability_repo.get(member_id, revoked_type) is None
updated_member_records = service_availability_repo.list_by_member(member_id)
updated_service_types = {r.ServiceTypeId for r in updated_member_records}
expected_remaining = set(service_type_ids) - {revoked_type}
assert updated_service_types == expected_remaining
# Step 7: Clean up remaining records
for service_type_id in [1, 3]: # Types 1 and 3 should still exist
service_availability_repo.revoke(member_id, service_type_id)
# Step 8: Verify cleanup
final_member_records = service_availability_repo.list_by_member(member_id)
original_member_records = [r for r in final_member_records if r.ServiceAvailabilityId in granted_ids]
assert len(original_member_records) == 0
def test_complex_multi_member_scenario(service_availability_repo: ServiceAvailabilityRepository, clean_service_availability):
"""Test complex scenarios with multiple members and service types."""
# Create a realistic availability matrix:
# Member 1: Available for all service types (1,2,3)
# Member 2: Available for morning services (1,2)
# Member 3: Available for evening service only (3)
# Member 4: Not available for any services
availability_matrix = [
(1, 1), (1, 2), (1, 3), # Member 1: all services
(2, 1), (2, 2), # Member 2: morning only
# Member 3: no services (doesn't exist in seeded data)
]
# Grant all availabilities
for member_id, service_type_id in availability_matrix:
service_availability_repo.grant(member_id, service_type_id)
# Test service type 1 (should have members 1,2)
type1_members = service_availability_repo.members_for_type(1)
assert set(type1_members) == {1, 2}
# Test service type 2 (should have members 1,2)
type2_members = service_availability_repo.members_for_type(2)
assert set(type2_members) == {1, 2}
# Test service type 3 (should have member 1 only)
type3_members = service_availability_repo.members_for_type(3)
assert set(type3_members) == {1}
# Test member 1 (should have all service types)
member1_records = service_availability_repo.list_by_member(1)
member1_types = {r.ServiceTypeId for r in member1_records}
assert member1_types == {1, 2, 3}
# Test member 2 (should have types 1,2)
member2_records = service_availability_repo.list_by_member(2)
member2_types = {r.ServiceTypeId for r in member2_records}
assert member2_types == {1, 2}
# Test nonexistent member (should have no services)
member3_records = service_availability_repo.list_by_member(3)
assert len(member3_records) == 0
# Simulate removing member 1 from evening service
service_availability_repo.revoke(1, 3)
# Type 3 should now have no members
updated_type3_members = service_availability_repo.members_for_type(3)
assert set(updated_type3_members) == set()
# Member 1 should now only have types 1,2
updated_member1_records = service_availability_repo.list_by_member(1)
updated_member1_types = {r.ServiceTypeId for r in updated_member1_records}
assert updated_member1_types == {1, 2}
def test_service_availability_repository_consistency_under_operations(
service_availability_repo: ServiceAvailabilityRepository,
clean_service_availability
):
"""Test repository consistency under various operations."""
# Create, modify, and delete records while verifying consistency
operations = [
('grant', 1, 1),
('grant', 1, 2),
('grant', 2, 1),
('revoke', 1, 1),
('grant', 1, 3),
('revoke', 2, 1),
('grant', 2, 2),
]
expected_state = set() # Track expected (member_id, service_type_id) pairs
for operation, member_id, service_type_id in operations:
if operation == 'grant':
service_availability_repo.grant(member_id, service_type_id)
expected_state.add((member_id, service_type_id))
elif operation == 'revoke':
service_availability_repo.revoke(member_id, service_type_id)
expected_state.discard((member_id, service_type_id))
# Verify consistency after each operation
all_records = service_availability_repo.list_all()
actual_pairs = {(r.MemberId, r.ServiceTypeId) for r in all_records if (r.MemberId, r.ServiceTypeId) in expected_state or (r.MemberId, r.ServiceTypeId) not in expected_state}
# Filter to only the pairs we've been working with
relevant_actual_pairs = {(r.MemberId, r.ServiceTypeId) for r in all_records
if r.MemberId in [1, 2] and r.ServiceTypeId in [1, 2, 3]}
assert relevant_actual_pairs == expected_state, f"Inconsistency after {operation}({member_id}, {service_type_id})"
# Verify each record can be retrieved individually
for member_id_check, service_type_id_check in expected_state:
record = service_availability_repo.get(member_id_check, service_type_id_check)
assert record is not None, f"Could not retrieve ({member_id_check}, {service_type_id_check})"

View File

@@ -1,62 +1,650 @@
# tests/test_service_type_repo.py
# backend/tests/repositories/test_service_type.py
# ------------------------------------------------------------
# Comprehensive pytest suite for the ServiceTypeRepository.
# ------------------------------------------------------------
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
import time
import uuid
from typing import List
from backend.models import ServiceType as ServiceTypeModel
from backend.repositories import ServiceTypeRepository
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.
"""
# ----------------------------------------------------------------------
# Helper utilities for test data
# ----------------------------------------------------------------------
def make_unique_name(base_name: str) -> str:
"""Generate a unique name by appending timestamp and uuid fragment."""
return f"{base_name}-{int(time.time())}-{uuid.uuid4().hex[:8]}"
# ----------------------------------------------------------------------
# 1⃣ Basic CRUD create, get_by_id, find_by_name
# ----------------------------------------------------------------------
def test_create_and_get_by_id(service_type_repo: ServiceTypeRepository):
"""Test basic service type creation and retrieval by ID."""
# Create a new service type with unique name
unique_name = make_unique_name("TestTimeSlot")
service_type = service_type_repo.create(unique_name)
# Verify creation
assert isinstance(service_type, ServiceTypeModel)
assert isinstance(service_type.ServiceTypeId, int)
assert service_type.ServiceTypeId > 0
assert service_type.TypeName == unique_name
# Retrieve the same service type by ID
fetched = service_type_repo.get_by_id(service_type.ServiceTypeId)
assert fetched is not None
assert fetched.ServiceTypeId == service_type.ServiceTypeId
assert fetched.TypeName == unique_name
def test_get_by_id_returns_none_when_missing(service_type_repo: ServiceTypeRepository):
"""Test that get_by_id returns None for nonexistent IDs."""
result = service_type_repo.get_by_id(99999)
assert result is None
def test_create_and_find_by_name(service_type_repo: ServiceTypeRepository):
"""Test service type creation and retrieval by name."""
type_name = make_unique_name("2PM Special Slot")
# Create a new service type
service_type = service_type_repo.create(type_name)
assert service_type.TypeName == type_name
# Find by name
found = service_type_repo.find_by_name(type_name)
assert found is not None
assert found.ServiceTypeId == service_type.ServiceTypeId
assert found.TypeName == type_name
def test_find_by_name_returns_none_when_missing(service_type_repo: ServiceTypeRepository):
"""Test that find_by_name returns None for nonexistent names."""
result = service_type_repo.find_by_name("NonexistentSlot-XYZ-123")
assert result is None
def test_create_with_various_names(service_type_repo: ServiceTypeRepository):
"""Test creating service types with various name formats."""
# Use unique names to avoid conflicts
test_names = [
make_unique_name("Test8AM"),
make_unique_name("Test11:30AM"),
make_unique_name("Evening Service"),
make_unique_name("Saturday 8PM"),
make_unique_name("Special Event - Christmas"),
make_unique_name("Mid-Week 7:30PM"),
]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
assert service_type.ServiceTypeId > 0
# All should be retrievable by name
for i, name in enumerate(test_names):
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.ServiceTypeId == created_types[i].ServiceTypeId
assert found.TypeName == name
# ----------------------------------------------------------------------
# 2⃣ list_all bulk operations and ordering
# ----------------------------------------------------------------------
def test_list_all(service_type_repo: ServiceTypeRepository):
"""Test listing all service types."""
# Get initial count
initial_types = service_type_repo.list_all()
initial_count = len(initial_types)
# Create multiple service types with unique names
test_names = [make_unique_name("Evening6PM"), make_unique_name("Morning8AM"), make_unique_name("Midday12PM"), make_unique_name("Afternoon3PM")]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# List all service types
all_types = service_type_repo.list_all()
assert len(all_types) == initial_count + len(test_names)
# Verify all created types are present
created_ids = {st.ServiceTypeId for st in created_types}
fetched_ids = {st.ServiceTypeId for st in all_types}
assert created_ids.issubset(fetched_ids)
def test_list_all_alphabetical_ordering(service_type_repo: ServiceTypeRepository):
"""Test that list_all returns results in alphabetical order."""
# Get all service types multiple times
for _ in range(3):
all_types = service_type_repo.list_all()
type_names = [st.TypeName for st in all_types]
# Should be in alphabetical order
assert type_names == sorted(type_names)
def test_list_all_contains_seeded_slots(service_type_repo: ServiceTypeRepository):
"""Test that list_all contains the expected seeded service types."""
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"}
# The seed fixture should have inserted these three names
expected_names = {"9AM", "11AM", "6PM"}
actual_names = set(names)
assert expected_names.issubset(actual_names)
# Because ``list_all`` orders by ``TypeName ASC`` we expect alphabetical order.
# Should be in 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.
# ----------------------------------------------------------------------
# 3⃣ ensure_slots bulk operations and idempotency
# ----------------------------------------------------------------------
def test_ensure_slots_creates_missing(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots creates missing service types."""
desired_slots = [make_unique_name("Morning8AM"), make_unique_name("Afternoon2PM"), make_unique_name("Evening7PM")]
# Initially, none should exist
for slot_name in desired_slots:
assert service_type_repo.find_by_name(slot_name) is None
# Ensure all slots exist
result = service_type_repo.ensure_slots(desired_slots)
# All should now be returned
assert len(result) == 3
result_names = {st.TypeName for st in result}
assert result_names == set(desired_slots)
# All should be findable individually
for slot_name in desired_slots:
found = service_type_repo.find_by_name(slot_name)
assert found is not None
assert found.TypeName == slot_name
def test_ensure_slots_returns_existing(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots returns existing service types without creating duplicates."""
# Create some service types first
existing_names = [make_unique_name("Pre-existing1"), make_unique_name("Pre-existing2")]
created_types = []
for name in existing_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# Ensure these same slots (should not create duplicates)
result = service_type_repo.ensure_slots(existing_names)
# Should return the same objects (same IDs)
assert len(result) == 2
result_ids = {st.ServiceTypeId for st in result}
created_ids = {st.ServiceTypeId for st in created_types}
assert result_ids == created_ids
def test_ensure_slots_mixed_existing_and_new(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots with a mix of existing and new service types."""
# Create some existing service types
existing_names = [make_unique_name("Existing1"), make_unique_name("Existing2")]
created_types = {}
for name in existing_names:
service_type = service_type_repo.create(name)
created_types[name] = service_type
# Request both existing and new slots
new_names = [make_unique_name("New1"), make_unique_name("New2")]
all_desired = existing_names + new_names
result = service_type_repo.ensure_slots(all_desired)
# Should return all 4 service types
assert len(result) == 4
result_names = {st.TypeName for st in result}
assert result_names == set(all_desired)
# Existing ones should have same IDs
for existing_name in existing_names:
result_item = next(st for st in result if st.TypeName == existing_name)
assert result_item.ServiceTypeId == created_types[existing_name].ServiceTypeId
def test_ensure_slots_is_idempotent(service_type_repo: ServiceTypeRepository):
"""Test that ensure_slots is idempotent (multiple calls don't create duplicates)."""
# First call inserts the three seed rows plus a new one
wanted = ["9AM", "11AM", "6PM", "3PM"]
result_first = service_type_repo.ensure_slots(wanted)
# All four names must now exist.
# All four names must now exist
assert {s.TypeName for s in result_first} == set(wanted)
# Capture the IDs for later comparison.
# Capture the IDs for later comparison
ids_before = {s.TypeName: s.ServiceTypeId for s in result_first}
# Second call should *not* create new rows.
# 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).
# IDs must be unchanged (no duplicates were added)
assert ids_before == ids_after
assert len(result_second) == len(wanted)
assert len(result_second) == len(wanted)
def test_ensure_slots_empty_list(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots with empty list."""
result = service_type_repo.ensure_slots([])
assert result == []
def test_ensure_slots_with_duplicates_in_input(service_type_repo: ServiceTypeRepository):
"""Test ensure_slots when input list contains duplicates."""
# This test reveals that ensure_slots has a limitation - it doesn't handle
# duplicates within the input list properly. It only checks existing slots
# at the start, not newly created ones within the same call.
# Create the unique slots first
base1 = make_unique_name("Morning")
base2 = make_unique_name("Evening")
base3 = make_unique_name("Afternoon")
# Create them individually first
service_type_repo.create(base1)
service_type_repo.create(base2)
service_type_repo.create(base3)
# Now test with duplicates - should work since they all exist
desired_slots = [base1, base2, base1, base3, base2]
result = service_type_repo.ensure_slots(desired_slots)
# Should return the same structure as input
assert len(result) == len(desired_slots)
# Should contain only the unique names
result_names = {st.TypeName for st in result}
expected_unique = {base1, base2, base3}
assert result_names == expected_unique
# Verify that duplicates in result refer to same objects
base1_objects = [st for st in result if st.TypeName == base1]
base2_objects = [st for st in result if st.TypeName == base2]
# Should have 2 copies of base1 and base2 each
assert len(base1_objects) == 2
assert len(base2_objects) == 2
# All copies should have same ID (same object references)
assert base1_objects[0].ServiceTypeId == base1_objects[1].ServiceTypeId
assert base2_objects[0].ServiceTypeId == base2_objects[1].ServiceTypeId
# ----------------------------------------------------------------------
# 4⃣ Edge cases and error conditions
# ----------------------------------------------------------------------
def test_get_by_id_with_invalid_ids(service_type_repo: ServiceTypeRepository):
"""Test get_by_id with various invalid ID values."""
invalid_ids = [-1, 0, 99999, -999]
for invalid_id in invalid_ids:
result = service_type_repo.get_by_id(invalid_id)
assert result is None
def test_find_by_name_case_sensitivity(service_type_repo: ServiceTypeRepository):
"""Test that find_by_name is case-sensitive."""
# Create with exact case
unique_name = make_unique_name("MorningSlot")
service_type_repo.create(unique_name)
# Exact case should work
exact = service_type_repo.find_by_name(unique_name)
assert exact is not None
# Different cases should return None
assert service_type_repo.find_by_name(unique_name.lower()) is None
assert service_type_repo.find_by_name(unique_name.upper()) is None
def test_find_by_name_with_whitespace(service_type_repo: ServiceTypeRepository):
"""Test find_by_name behavior with whitespace variations."""
# Create with exact spacing
unique_name = make_unique_name("Morning Service")
service_type_repo.create(unique_name)
# Exact spacing should work
exact = service_type_repo.find_by_name(unique_name)
assert exact is not None
# Different spacing should return None
assert service_type_repo.find_by_name(f" {unique_name}") is None
assert service_type_repo.find_by_name(f"{unique_name} ") is None
def test_find_by_name_with_empty_string(service_type_repo: ServiceTypeRepository):
"""Test find_by_name with empty string."""
result = service_type_repo.find_by_name("")
assert result is None
def test_create_with_special_characters(service_type_repo: ServiceTypeRepository):
"""Test creating service types with special characters."""
special_names = [
make_unique_name("Morning@8AM"),
make_unique_name("Evening-Service"),
make_unique_name("Special Event (Christmas)"),
make_unique_name("Mid-Week & Youth"),
make_unique_name("Saturday: 7:30PM"),
make_unique_name("Service #1"),
make_unique_name("Slot with 'quotes'"),
make_unique_name('Double"Quote"Service'),
make_unique_name("Unicode Service 🎵"),
]
created_types = []
for name in special_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
# Should be findable
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.ServiceTypeId == service_type.ServiceTypeId
# All should have unique IDs
ids = [st.ServiceTypeId for st in created_types]
assert len(set(ids)) == len(ids)
# ----------------------------------------------------------------------
# 5⃣ Data integrity and consistency tests
# ----------------------------------------------------------------------
def test_service_type_model_data_integrity(service_type_repo: ServiceTypeRepository):
"""Test that ServiceType model preserves data integrity."""
original_name = make_unique_name("DataIntegrityTest")
service_type = service_type_repo.create(original_name)
original_id = service_type.ServiceTypeId
# Retrieve and verify data is preserved
retrieved_by_id = service_type_repo.get_by_id(original_id)
assert retrieved_by_id is not None
assert retrieved_by_id.ServiceTypeId == original_id
assert retrieved_by_id.TypeName == original_name
# Retrieve by name and verify consistency
retrieved_by_name = service_type_repo.find_by_name(original_name)
assert retrieved_by_name is not None
assert retrieved_by_name.ServiceTypeId == original_id
assert retrieved_by_name.TypeName == original_name
# Both retrievals should be equivalent
assert retrieved_by_id.ServiceTypeId == retrieved_by_name.ServiceTypeId
assert retrieved_by_id.TypeName == retrieved_by_name.TypeName
def test_unique_name_constraint(service_type_repo: ServiceTypeRepository):
"""Test that service type names are unique (database constraint)."""
name = make_unique_name("UniqueTestSlot")
# Create first service type
first = service_type_repo.create(name)
assert first.TypeName == name
# Attempting to create another with same name should raise an error
with pytest.raises(Exception): # SQLite IntegrityError for unique constraint
service_type_repo.create(name)
def test_cross_method_consistency(service_type_repo: ServiceTypeRepository):
"""Test consistency across different query methods."""
# Create multiple service types
test_names = [make_unique_name("Alpha Service"), make_unique_name("Beta Service"), make_unique_name("Gamma Service")]
created_types = []
for name in test_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
# Test list_all consistency
all_types = service_type_repo.list_all()
all_names = {st.TypeName for st in all_types}
assert set(test_names).issubset(all_names)
# Test individual retrieval consistency
for i, name in enumerate(test_names):
# Get by ID
by_id = service_type_repo.get_by_id(created_types[i].ServiceTypeId)
assert by_id is not None
assert by_id.TypeName == name
# Find by name
by_name = service_type_repo.find_by_name(name)
assert by_name is not None
assert by_name.ServiceTypeId == created_types[i].ServiceTypeId
# Both methods should return equivalent objects
assert by_id.ServiceTypeId == by_name.ServiceTypeId
assert by_id.TypeName == by_name.TypeName
# ----------------------------------------------------------------------
# 6⃣ Parameterized tests for comprehensive coverage
# ----------------------------------------------------------------------
@pytest.mark.parametrize("base_name", [
"8AM", # Changed from 9AM to avoid conflict with seeded data
"11:30AM",
"Evening Service",
"Saturday Special",
"Mid-Week Bible Study",
"Youth Service - Friday",
"Sunday Morning Worship",
"Christmas Eve Service",
])
def test_create_and_retrieve_various_names(service_type_repo: ServiceTypeRepository, base_name: str):
"""Test creating and retrieving service types with various name formats."""
# Create unique name to avoid conflicts
type_name = make_unique_name(base_name)
# Create
created = service_type_repo.create(type_name)
assert created.TypeName == type_name
assert isinstance(created.ServiceTypeId, int)
assert created.ServiceTypeId > 0
# Retrieve by ID
by_id = service_type_repo.get_by_id(created.ServiceTypeId)
assert by_id is not None
assert by_id.ServiceTypeId == created.ServiceTypeId
assert by_id.TypeName == type_name
# Retrieve by name
by_name = service_type_repo.find_by_name(type_name)
assert by_name is not None
assert by_name.ServiceTypeId == created.ServiceTypeId
assert by_name.TypeName == type_name
@pytest.mark.parametrize("slot_count", [1, 2, 5, 10])
def test_ensure_slots_various_counts(service_type_repo: ServiceTypeRepository, slot_count: int):
"""Test ensure_slots with various numbers of slots."""
# Generate unique slot names
slot_names = [make_unique_name(f"Slot{i}") for i in range(1, slot_count + 1)]
# Ensure all slots
result = service_type_repo.ensure_slots(slot_names)
# Should return all requested slots
assert len(result) == slot_count
result_names = {st.TypeName for st in result}
assert result_names == set(slot_names)
# All should be findable
for name in slot_names:
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.TypeName == name
# ----------------------------------------------------------------------
# 7⃣ Integration and workflow tests
# ----------------------------------------------------------------------
def test_complete_service_type_workflow(service_type_repo: ServiceTypeRepository):
"""Test a complete workflow with multiple operations."""
# Step 1: Check initial state
initial_count = len(service_type_repo.list_all())
# Step 2: Create new service types
new_names = [make_unique_name("Morning Worship"), make_unique_name("Evening Prayer"), make_unique_name("Youth Meeting")]
created_types = []
for name in new_names:
service_type = service_type_repo.create(name)
created_types.append(service_type)
assert service_type.TypeName == name
# Step 3: Verify they exist in list_all
all_types = service_type_repo.list_all()
assert len(all_types) == initial_count + 3
created_ids = {st.ServiceTypeId for st in created_types}
all_ids = {st.ServiceTypeId for st in all_types}
assert created_ids.issubset(all_ids)
# Step 4: Test individual retrieval
for service_type in created_types:
# By ID
by_id = service_type_repo.get_by_id(service_type.ServiceTypeId)
assert by_id is not None
assert by_id.ServiceTypeId == service_type.ServiceTypeId
# By name
by_name = service_type_repo.find_by_name(service_type.TypeName)
assert by_name is not None
assert by_name.ServiceTypeId == service_type.ServiceTypeId
# Step 5: Test ensure_slots with mix of existing and new
additional_names = [make_unique_name("Additional Slot 1"), make_unique_name("Additional Slot 2")]
all_desired = new_names + additional_names
ensured = service_type_repo.ensure_slots(all_desired)
# Should return all 5 service types
assert len(ensured) == 5
ensured_names = {st.TypeName for st in ensured}
assert ensured_names == set(all_desired)
# Original service types should have same IDs
for original in created_types:
matching = next(st for st in ensured if st.TypeName == original.TypeName)
assert matching.ServiceTypeId == original.ServiceTypeId
def test_bulk_operations_consistency(service_type_repo: ServiceTypeRepository):
"""Test consistency when performing bulk operations."""
# Create initial batch
initial_names = [make_unique_name("Slot A"), make_unique_name("Slot B"), make_unique_name("Slot C")]
for name in initial_names:
service_type_repo.create(name)
# Use ensure_slots to add more
additional_names = [make_unique_name("Slot D"), make_unique_name("Slot E")]
all_names = initial_names + additional_names
result = service_type_repo.ensure_slots(all_names)
# Should return all service types
assert len(result) == 5
result_names = {st.TypeName for st in result}
assert result_names == set(all_names)
# Verify consistency with list_all
all_from_list = service_type_repo.list_all()
list_names = {st.TypeName for st in all_from_list}
assert set(all_names).issubset(list_names)
# Verify each can be found individually
for name in all_names:
found = service_type_repo.find_by_name(name)
assert found is not None
assert found.TypeName == name
def test_repository_scalability_and_performance(service_type_repo: ServiceTypeRepository):
"""Test repository behavior with a larger number of service types."""
# Create multiple service types with unique names
service_type_count = 25
base_name = make_unique_name("ScalabilityTest")
service_types = []
# Create service types
for i in range(service_type_count):
name = f"{base_name}-{i:03d}"
service_type = service_type_repo.create(name)
service_types.append(service_type)
assert service_type.TypeName == name
# Verify list_all returns all and maintains order
all_types = service_type_repo.list_all()
created_names = {st.TypeName for st in service_types}
all_names = {st.TypeName for st in all_types}
assert created_names.issubset(all_names)
# Should be in alphabetical order
all_names_list = [st.TypeName for st in all_types]
assert all_names_list == sorted(all_names_list)
# Test random access patterns
import random
test_indices = random.sample(range(service_type_count), min(5, service_type_count))
for i in test_indices:
expected_name = f"{base_name}-{i:03d}"
expected_id = service_types[i].ServiceTypeId
# Test retrieval by ID
by_id = service_type_repo.get_by_id(expected_id)
assert by_id is not None
assert by_id.TypeName == expected_name
# Test retrieval by name
by_name = service_type_repo.find_by_name(expected_name)
assert by_name is not None
assert by_name.ServiceTypeId == expected_id
def test_concurrent_operation_simulation(service_type_repo: ServiceTypeRepository):
"""Simulate concurrent operations to test repository consistency."""
# Simulate what might happen if multiple processes/threads were creating service types
base_names = [make_unique_name("Morning"), make_unique_name("Evening"), make_unique_name("Afternoon")]
# Multiple "processes" trying to ensure the same slots exist
results = []
for _ in range(3):
result = service_type_repo.ensure_slots(base_names)
results.append(result)
# Should always return the same service types
assert len(result) == 3
result_names = {st.TypeName for st in result}
assert result_names == set(base_names)
# All results should have the same IDs for the same names
first_result = results[0]
first_name_to_id = {st.TypeName: st.ServiceTypeId for st in first_result}
for result in results[1:]:
result_name_to_id = {st.TypeName: st.ServiceTypeId for st in result}
assert first_name_to_id == result_name_to_id
# Verify only one of each was actually created
all_types = service_type_repo.list_all()
all_names = [st.TypeName for st in all_types]
for name in base_names:
count = all_names.count(name)
assert count == 1, f"Found {count} instances of {name}, expected 1"