fix: harden admin access, repair ORM joins, and add migration/tests

This commit is contained in:
dwindown
2026-04-01 14:59:54 +07:00
parent de592d140e
commit 16ab13e911
21 changed files with 1275 additions and 368 deletions

View File

@@ -0,0 +1,12 @@
from sqlalchemy.orm import configure_mappers
def test_sqlalchemy_mappers_configure_without_join_errors():
"""
Ensure relationship joins are fully resolvable.
This catches missing FK/primaryjoin regressions early.
"""
import app.models # noqa: F401
configure_mappers()

View File

@@ -1,275 +1,77 @@
#!/usr/bin/env python3
"""
Test script for normalization calculations.
This script tests the normalization functions to ensure they work correctly
without requiring database connections.
"""
import sys
import math
import os
import sys
# Add the project root to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import pytest
# Ensure project root is importable when tests run in isolated environments.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services.normalization import apply_normalization
def test_apply_normalization():
"""Test the apply_normalization function."""
print("Testing apply_normalization function...")
print("=" * 60)
# Test case 1: Normal normalization (NM=500, rataan=500, sb=100)
nm1 = 500
rataan1 = 500
sb1 = 100
nn1 = apply_normalization(nm1, rataan1, sb1)
expected1 = 500
print(f"Test 1: NM={nm1}, rataan={rataan1}, sb={sb1}")
print(f" Expected NN: {expected1}")
print(f" Actual NN: {nn1}")
print(f" Status: {'PASS' if nn1 == expected1 else 'FAIL'}")
print()
# Test case 2: High score (NM=600, rataan=500, sb=100)
nm2 = 600
rataan2 = 500
sb2 = 100
nn2 = apply_normalization(nm2, rataan2, sb2)
expected2 = 600
print(f"Test 2: NM={nm2}, rataan={rataan2}, sb={sb2}")
print(f" Expected NN: {expected2}")
print(f" Actual NN: {nn2}")
print(f" Status: {'PASS' if nn2 == expected2 else 'FAIL'}")
print()
# Test case 3: Low score (NM=400, rataan=500, sb=100)
nm3 = 400
rataan3 = 500
sb3 = 100
nn3 = apply_normalization(nm3, rataan3, sb3)
expected3 = 400
print(f"Test 3: NM={nm3}, rataan={rataan3}, sb={sb3}")
print(f" Expected NN: {expected3}")
print(f" Actual NN: {nn3}")
print(f" Status: {'PASS' if nn3 == expected3 else 'FAIL'}")
print()
# Test case 4: Edge case - maximum NM
nm4 = 1000
rataan4 = 500
sb4 = 100
nn4 = apply_normalization(nm4, rataan4, sb4)
expected4 = 1000
print(f"Test 4: NM={nm4}, rataan={rataan4}, sb={sb4}")
print(f" Expected NN: {expected4}")
print(f" Actual NN: {nn4}")
print(f" Status: {'PASS' if nn4 == expected4 else 'FAIL'}")
print()
# Test case 5: Edge case - minimum NM
nm5 = 0
rataan5 = 500
sb5 = 100
nn5 = apply_normalization(nm5, rataan5, sb5)
expected5 = 0
print(f"Test 5: NM={nm5}, rataan={rataan5}, sb={sb5}")
print(f" Expected NN: {expected5}")
print(f" Actual NN: {nn5}")
print(f" Status: {'PASS' if nn5 == expected5 else 'FAIL'}")
print()
# Test case 6: Error case - invalid NM (above max)
try:
nm6 = 1200 # Above valid range
rataan6 = 500
sb6 = 100
nn6 = apply_normalization(nm6, rataan6, sb6)
print(f"Test 6: NM={nm6}, rataan={rataan6}, sb={sb6} (should raise ValueError)")
print(f" Status: FAIL - Should have raised ValueError")
except ValueError as e:
print(f"Test 6: NM={nm6}, rataan={rataan6}, sb={sb6} (should raise ValueError)")
print(f" Error: {e}")
print(f" Status: PASS - Correctly raised ValueError")
print()
# Test case 7: Error case - invalid NM (below min)
try:
nm7 = -100 # Below valid range
rataan7 = 500
sb7 = 100
nn7 = apply_normalization(nm7, rataan7, sb7)
print(f"Test 7: NM={nm7}, rataan={rataan7}, sb={sb7} (should raise ValueError)")
print(f" Status: FAIL - Should have raised ValueError")
except ValueError as e:
print(f"Test 7: NM={nm7}, rataan={rataan7}, sb={sb7} (should raise ValueError)")
print(f" Error: {e}")
print(f" Status: PASS - Correctly raised ValueError")
print()
# Test case 8: Different rataan/sb (NM=500, rataan=600, sb=80)
nm8 = 500
rataan8 = 600
sb8 = 80
nn8 = apply_normalization(nm8, rataan8, sb8)
# z_score = (500 - 600) / 80 = -1.25
# nn = 500 + 100 * (-1.25) = 500 - 125 = 375
expected8 = 375
print(f"Test 8: NM={nm8}, rataan={rataan8}, sb={sb8}")
print(f" Expected NN: {expected8}")
print(f" Actual NN: {nn8}")
print(f" Status: {'PASS' if nn8 == expected8 else 'FAIL'}")
print()
# Test case 9: Error case - invalid NM
try:
nm9 = 1500 # Above valid range
rataan9 = 500
sb9 = 100
nn9 = apply_normalization(nm9, rataan9, sb9)
print(f"Test 9: NM={nm9}, rataan={rataan9}, sb={sb9} (should raise ValueError)")
print(f" Status: FAIL - Should have raised ValueError")
except ValueError as e:
print(f"Test 9: NM=1500, rataan=500, sb=100 (should raise ValueError)")
print(f" Error: {e}")
print(f" Status: PASS - Correctly raised ValueError")
print()
# Test case 10: Error case - invalid sb
try:
nm10 = 500
rataan10 = 500
sb10 = 0 # Invalid SD
nn10 = apply_normalization(nm10, rataan10, sb10)
expected10 = 500 # Should return default when sb <= 0
print(f"Test 10: NM={nm10}, rataan={rataan10}, sb={sb10} (should return default)")
print(f" Expected NN: {expected10}")
print(f" Actual NN: {nn10}")
print(f" Status: {'PASS' if nn10 == expected10 else 'FAIL'}")
except Exception as e:
print(f"Test 10: NM=500, rataan=500, sb=0 (should return default)")
print(f" Error: {e}")
print(f" Status: FAIL - Should have returned default value")
print()
print("=" * 60)
print("All tests completed!")
print("=" * 60)
@pytest.mark.parametrize(
("nm", "rataan", "sb", "expected"),
[
(500, 500, 100, 500),
(600, 500, 100, 600),
(400, 500, 100, 400),
(1000, 500, 100, 1000),
(0, 500, 100, 0),
(500, 600, 80, 375),
],
)
def test_apply_normalization_nominal_cases(nm: int, rataan: float, sb: float, expected: int):
assert apply_normalization(nm, rataan, sb) == expected
def calculate_dynamic_mean_and_std(nm_values):
"""
Calculate mean and standard deviation from a list of NM values.
This simulates what update_dynamic_normalization does.
"""
n = len(nm_values)
if n == 0:
return None, None
# Calculate mean
mean = sum(nm_values) / n
# Calculate variance (population variance)
if n > 1:
variance = sum((x - mean) ** 2 for x in nm_values) / n
std = variance ** 0.5
else:
std = 0.0
return mean, std
@pytest.mark.parametrize("nm", [-1, 1001, 1500, -100])
def test_apply_normalization_rejects_invalid_nm(nm: int):
with pytest.raises(ValueError):
apply_normalization(nm, 500, 100)
def test_dynamic_normalization_simulation():
"""Test dynamic normalization with simulated participant scores."""
print("\nTesting dynamic normalization simulation...")
print("=" * 60)
@pytest.mark.parametrize("sb", [0, -1, -100.0])
def test_apply_normalization_returns_default_when_sd_non_positive(sb: float):
assert apply_normalization(500, 500, sb) == 500
# Simulate 10 participant NM scores
def test_dynamic_normalization_distribution_behaves_as_expected():
nm_scores = [450, 480, 500, 520, 550, 480, 510, 490, 530, 470]
print(f"Simulated NM scores: {nm_scores}")
print()
# Calculate mean and SD
mean, std = calculate_dynamic_mean_and_std(nm_scores)
print(f"Calculated mean (rataan): {mean:.2f}")
print(f"Calculated SD (sb): {std:.2f}")
print()
mean = sum(nm_scores) / len(nm_scores)
variance = sum((x - mean) ** 2 for x in nm_scores) / len(nm_scores)
std = math.sqrt(variance)
# Normalize each score
print("Normalized scores:")
for i, nm in enumerate(nm_scores):
nn = apply_normalization(nm, mean, std)
print(f" Participant {i+1}: NM={nm:3d} -> NN={nn:3d}")
print()
# Check if normalized distribution is close to mean=500, SD=100
nn_scores = [apply_normalization(nm, mean, std) for nm in nm_scores]
nn_mean, nn_std = calculate_dynamic_mean_and_std(nn_scores)
nn_mean = sum(nn_scores) / len(nn_scores)
nn_variance = sum((x - nn_mean) ** 2 for x in nn_scores) / len(nn_scores)
nn_std = math.sqrt(nn_variance)
print(f"Normalized distribution:")
print(f" Mean: {nn_mean:.2f} (target: 500 ± 5)")
print(f" SD: {nn_std:.2f} (target: 100 ± 5)")
print(f" Status: {'PASS' if abs(nn_mean - 500) <= 5 and abs(nn_std - 100) <= 5 else 'NEAR PASS'}")
print()
print("=" * 60)
# Rounding in apply_normalization introduces small drift; these bounds are tight.
assert abs(nn_mean - 500) <= 5
assert abs(nn_std - 100) <= 5
def test_incremental_update():
"""Test incremental update of dynamic normalization."""
print("\nTesting incremental update simulation...")
print("=" * 60)
def test_incremental_population_stats_match_batch_stats():
scores = [500, 550, 450, 600, 400]
# Simulate adding scores incrementally
nm_scores = []
participant_count = 0
total_nm_sum = 0.0
total_nm_sq_sum = 0.0
new_scores = [500, 550, 450, 600, 400]
for i, nm in enumerate(new_scores):
# Update running statistics
for score in scores:
participant_count += 1
total_nm_sum += nm
total_nm_sq_sum += nm * nm
total_nm_sum += score
total_nm_sq_sum += score * score
# Calculate mean and SD
mean = total_nm_sum / participant_count
if participant_count > 1:
variance = (total_nm_sq_sum / participant_count) - (mean ** 2)
std = variance ** 0.5
else:
std = 0.0
incremental_mean = total_nm_sum / participant_count
incremental_variance = (total_nm_sq_sum / participant_count) - (incremental_mean**2)
incremental_std = math.sqrt(max(0.0, incremental_variance))
nm_scores.append(nm)
batch_mean = sum(scores) / len(scores)
batch_variance = sum((x - batch_mean) ** 2 for x in scores) / len(scores)
batch_std = math.sqrt(batch_variance)
print(f"After adding participant {i+1}:")
print(f" NM: {nm}")
print(f" Participant count: {participant_count}")
print(f" Mean (rataan): {mean:.2f}")
print(f" SD (sb): {std:.2f}")
print()
# Final calculation
final_mean, final_std = calculate_dynamic_mean_and_std(nm_scores)
print(f"Final statistics:")
print(f" All scores: {nm_scores}")
print(f" Mean: {final_mean:.2f}")
print(f" SD: {final_std:.2f}")
print()
print("=" * 60)
if __name__ == "__main__":
print("Normalization Calculation Tests")
print("=" * 60)
print()
test_apply_normalization()
test_dynamic_normalization_simulation()
test_incremental_update()
print("\nAll test simulations completed successfully!")
assert incremental_mean == pytest.approx(batch_mean, rel=0, abs=1e-10)
assert incremental_std == pytest.approx(batch_std, rel=0, abs=1e-10)