From 341333b268c9ef47b34ac47af59d8bfe38e12b11 Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Sun, 19 Apr 2026 16:32:26 -0400 Subject: [PATCH 1/7] pytest init --- backend/requirements-dev.txt | 6 ++++ backend/tests/SPEC.md | 28 ++++++++++++++++ backend/tests/__init__.py | 0 backend/tests/api/__init__.py | 0 backend/tests/conftest.py | 46 +++++++++++++++++++++++++++ backend/tests/integration/__init__.py | 0 backend/tests/unit/__init__.py | 0 pytest.ini | 19 +++++++++++ 8 files changed, 99 insertions(+) create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/SPEC.md create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 pytest.ini diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 00000000..c1414c65 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest==8.3.3 +pytest-cov==5.0.0 +httpx==0.27.2 +mutmut==2.5.0 +hypothesis==6.112.1 diff --git a/backend/tests/SPEC.md b/backend/tests/SPEC.md new file mode 100644 index 00000000..f53a72fa --- /dev/null +++ b/backend/tests/SPEC.md @@ -0,0 +1,28 @@ +# SUT Behavior Spec + +One short paragraph per module, written BEFORE black-box tests are designed. +This is the source of truth for EP / BA / EG test derivation (proposal §2.2). + +## scripts/utilities/password_generator.py +_TODO: expected inputs, valid ranges, guarantees about output composition._ + +## scripts/security/password_checker.py +_TODO: strength levels, rules that bump/penalize score, return shape._ + +## scripts/utilities/unit_converter.py +_TODO: supported unit families, error behavior for unsupported units._ + +## scripts/utilities/age_calculator.py +_TODO: accepted date formats, handling of future dates / invalid dates._ + +## scripts/utilities/currency_converter.py +_TODO: supported currencies, behavior when the network call fails (mocked)._ + +## scripts/productivity/todo_manager.py +_TODO: create/update/delete contract, persistence file, duplicate handling._ + +## scripts/productivity/reminder_system.py +_TODO: reminder fields, due-time semantics, persistence file._ + +## routers/auth/router.py +_TODO: endpoints, required fields, success/error status codes._ diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..1add1dac --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,46 @@ +import os +import random +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest + +BACKEND_DIR = Path(__file__).resolve().parent.parent +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + + +@pytest.fixture +def client(): + from fastapi.testclient import TestClient + from app import app + + return TestClient(app) + + +@pytest.fixture +def tmp_json_store(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def fixed_random(): + random.seed(0) + yield + random.seed() + + +@pytest.fixture +def frozen_time(): + fixed = datetime(2026, 4, 19, 12, 0, 0) + + class _FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + return fixed + + with patch("datetime.datetime", _FrozenDateTime): + yield fixed diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..7fe82fae --- /dev/null +++ b/pytest.ini @@ -0,0 +1,19 @@ +[pytest] +testpaths = backend/tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -ra + --strict-markers + --cov=backend/scripts + --cov=backend/routers + --cov-branch + --cov-report=term-missing + --cov-report=html:backend/tests/reports/coverage_html +markers = + blackbox: equivalence partitioning, boundary, error-guessing tests + whitebox: branch-coverage driven tests + integration: FastAPI + backend integration tests + api: REST API tests + slow: tests that hit the network or are otherwise slow From da642d8e1709bc7627d703cce4e5b0803af58d21 Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Sun, 19 Apr 2026 17:00:27 -0400 Subject: [PATCH 2/7] fix bugs --- backend/scripts/automation/__init__.py | 0 backend/scripts/data_tools/__init__.py | 0 backend/scripts/security/__init__.py | 0 backend/scripts/security/{firewall-sim.py => firewall_sim.py} | 0 .../security/{Ip Address- NetID _HostID .py => ip_address.py} | 0 backend/scripts/utilities/__init__.py | 0 backend/scripts/web_scraping/__init__.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/scripts/automation/__init__.py create mode 100644 backend/scripts/data_tools/__init__.py create mode 100644 backend/scripts/security/__init__.py rename backend/scripts/security/{firewall-sim.py => firewall_sim.py} (100%) rename backend/scripts/security/{Ip Address- NetID _HostID .py => ip_address.py} (100%) create mode 100644 backend/scripts/utilities/__init__.py create mode 100644 backend/scripts/web_scraping/__init__.py diff --git a/backend/scripts/automation/__init__.py b/backend/scripts/automation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/scripts/data_tools/__init__.py b/backend/scripts/data_tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/scripts/security/__init__.py b/backend/scripts/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/scripts/security/firewall-sim.py b/backend/scripts/security/firewall_sim.py similarity index 100% rename from backend/scripts/security/firewall-sim.py rename to backend/scripts/security/firewall_sim.py diff --git a/backend/scripts/security/Ip Address- NetID _HostID .py b/backend/scripts/security/ip_address.py similarity index 100% rename from backend/scripts/security/Ip Address- NetID _HostID .py rename to backend/scripts/security/ip_address.py diff --git a/backend/scripts/utilities/__init__.py b/backend/scripts/utilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/scripts/web_scraping/__init__.py b/backend/scripts/web_scraping/__init__.py new file mode 100644 index 00000000..e69de29b From a7d83075fdca8d38cc1e44d1d3085cd9560bc723 Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Sun, 19 Apr 2026 17:03:32 -0400 Subject: [PATCH 3/7] refactor folder --- backend/tests/SPEC.md | 12 ++++++++++++ backend/tests/{unit => blackbox}/__init__.py | 0 backend/tests/whitebox/__init__.py | 0 3 files changed, 12 insertions(+) rename backend/tests/{unit => blackbox}/__init__.py (100%) create mode 100644 backend/tests/whitebox/__init__.py diff --git a/backend/tests/SPEC.md b/backend/tests/SPEC.md index f53a72fa..aa69099c 100644 --- a/backend/tests/SPEC.md +++ b/backend/tests/SPEC.md @@ -24,5 +24,17 @@ _TODO: create/update/delete contract, persistence file, duplicate handling._ ## scripts/productivity/reminder_system.py _TODO: reminder fields, due-time semantics, persistence file._ +## scripts/data_tools/data_converter.py +_TODO: supported source/target formats, schema of accepted inputs, error paths for unsupported or malformed data._ + +## scripts/security/ip_address.py +_TODO: accepted IP/mask formats, NetID/HostID derivation rules, behavior on invalid octets or masks (boundary-heavy)._ + +## scripts/web_scraping/weather_checker.py +_TODO: expected request URL/params, parsing contract over the JSON/HTML response, behavior when network call fails, times out, or returns malformed data. Network is mocked._ + +## scripts/automation/file_organizer.py +_TODO: classification rules (extension -> category folder), handling of unknown extensions, duplicates, hidden files. Filesystem ops tested against tmp_path._ + ## routers/auth/router.py _TODO: endpoints, required fields, success/error status codes._ diff --git a/backend/tests/unit/__init__.py b/backend/tests/blackbox/__init__.py similarity index 100% rename from backend/tests/unit/__init__.py rename to backend/tests/blackbox/__init__.py diff --git a/backend/tests/whitebox/__init__.py b/backend/tests/whitebox/__init__.py new file mode 100644 index 00000000..e69de29b From d3478abc5da17d669ee7683d71cff259397b2166 Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Mon, 20 Apr 2026 22:10:55 -0400 Subject: [PATCH 4/7] test: add fixed_secrets fixture --- backend/tests/conftest.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1add1dac..6906da90 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,6 +33,30 @@ def fixed_random(): random.seed() +@pytest.fixture +def fixed_secrets(monkeypatch): + import secrets as _secrets + + rng = random.Random(0) + + def _choice(seq): + return rng.choice(list(seq)) + + def _randbelow(n): + return rng.randrange(n) + + def _token_hex(nbytes=32): + return rng.randbytes(nbytes).hex() + + monkeypatch.setattr(_secrets, "choice", _choice) + monkeypatch.setattr(_secrets, "randbelow", _randbelow) + monkeypatch.setattr(_secrets, "token_hex", _token_hex) + monkeypatch.setattr( + _secrets.SystemRandom, "shuffle", lambda self, seq: rng.shuffle(seq) + ) + yield rng + + @pytest.fixture def frozen_time(): fixed = datetime(2026, 4, 19, 12, 0, 0) From 168c8686ebf4e863fda2c69b795adaa8baaaee0c Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Mon, 20 Apr 2026 22:05:07 -0400 Subject: [PATCH 5/7] test: add black-box tests for utilities and security modules --- backend/tests/blackbox/security/__init__.py | 0 .../security/test_password_checker.py | 204 +++++++++++++ backend/tests/blackbox/utilities/__init__.py | 0 .../blackbox/utilities/test_age_calculator.py | 193 ++++++++++++ .../utilities/test_password_generator.py | 274 ++++++++++++++++++ .../blackbox/utilities/test_unit_converter.py | 162 +++++++++++ 6 files changed, 833 insertions(+) create mode 100644 backend/tests/blackbox/security/__init__.py create mode 100644 backend/tests/blackbox/security/test_password_checker.py create mode 100644 backend/tests/blackbox/utilities/__init__.py create mode 100644 backend/tests/blackbox/utilities/test_age_calculator.py create mode 100644 backend/tests/blackbox/utilities/test_password_generator.py create mode 100644 backend/tests/blackbox/utilities/test_unit_converter.py diff --git a/backend/tests/blackbox/security/__init__.py b/backend/tests/blackbox/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/security/test_password_checker.py b/backend/tests/blackbox/security/test_password_checker.py new file mode 100644 index 00000000..65b6ee14 --- /dev/null +++ b/backend/tests/blackbox/security/test_password_checker.py @@ -0,0 +1,204 @@ +""" +Black-box tests for scripts.security.password_checker. + +Derived from SPEC.md §password_checker (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. Each test is labeled with technique. +""" +import pytest + +from scripts.security.password_checker import PasswordChecker + +pytestmark = pytest.mark.blackbox + + +@pytest.fixture +def checker(): + return PasswordChecker() + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestAnalyzePasswordEP: + """ + EP partitions for analyze_password: + input: empty vs non-empty + charset variety classes: lower-only / upper-only / digits-only / + special-only / mixed + length classes: <8 / 8..11 / 12..15 / >=16 + """ + + def test_empty_password_returns_error(self, checker): + # EP: empty string is the invalid-input class -> returns {'error': ...}. + result = checker.analyze_password("") + assert "error" in result + assert "password" not in result # confirm no partial analysis leaked + + def test_non_empty_password_returns_full_analysis(self, checker): + # EP: valid class -> returns the full analysis dict with documented keys. + result = checker.analyze_password("abcd") + for key in ("length", "entropy", "character_variety", "strength_score", + "strength_level", "crack_times", "common_patterns", + "dictionary_words", "is_common", "repeated_elements"): + assert key in result + + @pytest.mark.parametrize("pwd, expected_key", [ + ("abcdefgh", "lowercase"), + ("ABCDEFGH", "uppercase"), + ("12345678", "digits"), + ("!@#$%^&*", "special"), + ]) + def test_character_variety_classes_detected(self, checker, pwd, expected_key): + # EP: one representative per single-class variety partition. + variety = checker.analyze_password(pwd)["character_variety"] + assert variety[expected_key] is True + other_keys = {"lowercase", "uppercase", "digits", "special"} - {expected_key} + for k in other_keys: + assert variety[k] is False + + def test_common_password_flagged(self, checker): + # EP: password in common-passwords list -> is_common = True. + assert checker.analyze_password("password")["is_common"] is True + + def test_uncommon_password_not_flagged(self, checker): + # EP: password NOT in common list. + assert checker.analyze_password("zQ8!rTx%mN")["is_common"] is False + + +class TestCheckCommonPatternsEP: + def test_numeric_sequence_detected(self, checker): + # EP: sequential digits are a common pattern. + assert "123" in checker.check_common_patterns("abc123xyz") + + def test_keyboard_pattern_detected(self, checker): + # EP: keyboard-row pattern detection (labeled with 'keyboard pattern:'). + patterns = checker.check_common_patterns("zqwerty!") + assert any("qwerty" in p for p in patterns) + + def test_no_pattern_returns_empty_list(self, checker): + # EP: password with no known patterns -> empty list. + assert checker.check_common_patterns("xpfkHgvn") == [] + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestLengthBoundariesBA: + """Boundary analysis around length thresholds 8, 12, 16 (per SPEC).""" + + @pytest.mark.parametrize("length, expect_8, expect_12", [ + (7, False, False), # BA: length=7 (just-below 8-plus) + (8, True, False), # BA: length=8 (at 8-plus boundary) + (9, True, False), # BA: length=9 (just-above 8) + (11, True, False), # BA: length=11 (just-below 12-plus) + (12, True, True), # BA: length=12 (at 12-plus boundary) + (13, True, True), # BA: length=13 (just-above 12) + ]) + def test_length_thresholds(self, checker, length, expect_8, expect_12): + variety = checker.analyze_password("a" * length)["character_variety"] + assert variety["length_8_plus"] is expect_8 + assert variety["length_12_plus"] is expect_12 + + +class TestEntropyBoundariesBA: + """Boundary analysis around the entropy threshold used by calculate_entropy.""" + + def test_single_class_password_has_nonzero_entropy(self, checker): + # BA: minimum nonzero-entropy case - one-char class, length 1. + assert checker.calculate_entropy("a") > 0 + + def test_empty_string_entropy_is_zero(self, checker): + # BA: charset size = 0 corner -> entropy 0 per SPEC. + assert checker.calculate_entropy("") == 0 + + +class TestStrengthScoreBA: + """Boundary analysis on the strength_score cap at 12 (per SPEC).""" + + def test_strength_score_is_capped_at_12(self, checker): + # BA: for an exceptionally strong password, score must not exceed 12. + # Uses a pwd that hits every scoring rule: length>=16, all 4 variety, + # no common patterns / dict words / repeats / common pwd. + pwd = "Zq8!rTx%mN2&vB7#" + result = checker.analyze_password(pwd) + assert result["strength_score"] <= 12 + + +class TestStrengthLevelBA: + """Boundary analysis at each strength-level threshold (<=3, <=5, <=7, <=9, <=11).""" + + @pytest.mark.parametrize("score, expected_level", [ + (0, "Very Weak"), + (3, "Very Weak"), + (4, "Weak"), + (5, "Weak"), + (6, "Fair"), + (7, "Fair"), + (8, "Good"), + (9, "Good"), + (10, "Strong"), + (11, "Strong"), + (12, "Very Strong"), + ]) + def test_strength_level_thresholds(self, checker, score, expected_level): + # BA: exact thresholds between strength levels. + assert checker.get_strength_level(score) == expected_level + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_whitespace_only_password_does_not_crash(self, checker): + # EG: whitespace is neither alpha nor digit nor punctuation; charset=0. + result = checker.analyze_password(" ") + # Should treat length=4 chars but entropy=0 (no class matched). + assert result["length"] == 4 + assert result["entropy"] == 0 + + def test_unicode_password_does_not_crash(self, checker): + # EG: non-ASCII characters are an easily overlooked class. + result = checker.analyze_password("pässwoÑrd") + assert "strength_level" in result + + def test_long_password_reasonable_length(self, checker): + # EG: long-but-reasonable input. Ensures no crash for typical long pwd. + pwd = "Aa1!" * 32 # 128 chars + result = checker.analyze_password(pwd) + assert result["length"] == 128 + + def test_extremely_long_password_does_not_crash(self, checker): + # EG / FAULT-HUNTING: very long passwords push entropy so high that + # estimate_crack_time's `2**entropy` overflows Python floats. Per SPEC + # the contract is "returns full dict"; a crash violates it. This test + # is intentionally designed to FAIL until the overflow is handled. + # (See FINDINGS.md FAULT-002.) + pwd = "Aa1!" * 500 # 2000 chars + result = checker.analyze_password(pwd) + assert result["length"] == 2000 + + def test_repeated_single_character_triggers_repeat_detection(self, checker): + # EG: a classic bad-password pattern - all same char. + result = checker.analyze_password("aaaaaaaa") + assert result["repeated_elements"]["repeated_chars"] # non-empty + + def test_dictionary_word_inside_password_flagged(self, checker): + # EG: dictionary word embedded in an otherwise strong-looking password. + result = checker.analyze_password("Admin#42!xY") + assert "admin" in result["dictionary_words"] + + def test_estimate_crack_time_returns_four_scenarios(self, checker): + # EG: contract - exactly 4 documented attack scenarios per SPEC. + times = checker.estimate_crack_time("Aa1!Aa1!") + assert set(times.keys()) == { + "online_throttled", "online_unthrottled", "offline_slow", "offline_fast" + } + + def test_none_password_treated_as_empty(self, checker): + # EG: None is falsy and hits the same "empty" guard in analyze_password. + # Documented behavior: returns {'error': ...} dict, does not crash. + result = checker.analyze_password(None) + assert "error" in result diff --git a/backend/tests/blackbox/utilities/__init__.py b/backend/tests/blackbox/utilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/utilities/test_age_calculator.py b/backend/tests/blackbox/utilities/test_age_calculator.py new file mode 100644 index 00000000..6d0ec059 --- /dev/null +++ b/backend/tests/blackbox/utilities/test_age_calculator.py @@ -0,0 +1,193 @@ +""" +Black-box tests for scripts.utilities.age_calculator. + +Derived from SPEC.md §age_calculator (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. +""" +import datetime + +import pytest + +from scripts.utilities.age_calculator import AgeCalculator + +pytestmark = pytest.mark.blackbox + + +@pytest.fixture +def ac(): + return AgeCalculator() + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestParseDateEP: + """EP partitions for parse_date: each documented format class + invalid class.""" + + @pytest.mark.parametrize("s, expected", [ + ("2000-06-15", datetime.date(2000, 6, 15)), # %Y-%m-%d + ("15/06/2000", datetime.date(2000, 6, 15)), # %d/%m/%Y (first match) + ("15-06-2000", datetime.date(2000, 6, 15)), # %d-%m-%Y + ("2000/06/15", datetime.date(2000, 6, 15)), # %Y/%m/%d + ("15.06.2000", datetime.date(2000, 6, 15)), # %d.%m.%Y + ("June 15, 2000", datetime.date(2000, 6, 15)), # %B %d, %Y + ("15 June 2000", datetime.date(2000, 6, 15)), # %d %B %Y + ]) + def test_accepted_formats(self, ac, s, expected): + # EP: one representative per accepted format class. + assert ac.parse_date(s) == expected + + def test_unparseable_string_raises(self, ac): + # EP: invalid class -> ValueError per SPEC. + with pytest.raises(ValueError): + ac.parse_date("not-a-date-at-all") + + +class TestCalculateAgeEP: + def test_valid_birth_past_returns_positive_age(self, ac): + # EP: valid class (birth < current). + result = ac.calculate_age("2000-01-01", "2020-01-01") + assert result["years"] == 20 + + def test_same_day_zero_age(self, ac): + # EP: birth == current -> age 0. + result = ac.calculate_age("2020-06-15", "2020-06-15") + assert result["years"] == 0 + assert result["days"] == 0 + + def test_future_birth_date_raises(self, ac): + # EP: invalid class -> ValueError per SPEC. + with pytest.raises(ValueError): + ac.calculate_age("2099-01-01", "2020-01-01") + + +class TestZodiacEP: + """ + EP: each zodiac sign is an equivalence class. Test one representative + day for each of the 12 western signs (mid-range, not boundary days + which are covered in BA). + """ + @pytest.mark.parametrize("date_str, expected_sign", [ + ("2000-02-05", "Aquarius"), + ("2000-03-05", "Pisces"), + ("2000-04-05", "Aries"), + ("2000-05-05", "Taurus"), + ("2000-06-05", "Gemini"), + ("2000-07-05", "Cancer"), + ("2000-08-05", "Leo"), + ("2000-09-05", "Virgo"), + ("2000-10-05", "Libra"), + ("2000-11-05", "Scorpio"), + ("2000-12-05", "Sagittarius"), + ("2000-01-05", "Capricorn"), + ]) + def test_zodiac_sign_per_sign(self, ac, date_str, expected_sign): + assert ac.calculate_zodiac_sign(date_str)["sign"] == expected_sign + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestCalculateAgeBA: + def test_age_on_day_before_birthday(self, ac): + # BA: one day before birthday -> age not yet incremented. + result = ac.calculate_age("2000-06-15", "2020-06-14") + assert result["years"] == 19 + + def test_age_on_birthday(self, ac): + # BA: exact birthday anniversary -> age incremented. + result = ac.calculate_age("2000-06-15", "2020-06-15") + assert result["years"] == 20 + + def test_age_on_day_after_birthday(self, ac): + # BA: one day after birthday -> age still the same (no double-count). + result = ac.calculate_age("2000-06-15", "2020-06-16") + assert result["years"] == 20 + + def test_birth_one_day_before_current_has_one_day_total(self, ac): + # BA: minimum positive difference. + result = ac.calculate_age("2020-06-14", "2020-06-15") + assert result["days"] == 1 + assert result["years"] == 0 + + +class TestZodiacBoundariesBA: + """ + BA: zodiac sign boundaries. Each boundary day is the most fault-prone + input. Example: Gemini spans May 21 - Jun 20. Test May 20 (Taurus) and + May 21 (Gemini), Jun 20 (Gemini) and Jun 21 (Cancer). + """ + + @pytest.mark.parametrize("date_str, expected_sign", [ + # Aries / Taurus boundary (Apr 19 / Apr 20) + ("2000-04-19", "Aries"), + ("2000-04-20", "Taurus"), + # Taurus / Gemini boundary (May 20 / May 21) + ("2000-05-20", "Taurus"), + ("2000-05-21", "Gemini"), + # Gemini / Cancer boundary (Jun 20 / Jun 21) + ("2000-06-20", "Gemini"), + ("2000-06-21", "Cancer"), + # Sagittarius / Capricorn boundary (Dec 21 / Dec 22) + ("2000-12-21", "Sagittarius"), + ("2000-12-22", "Capricorn"), + # Capricorn / Aquarius boundary (Jan 19 / Jan 20) + ("2000-01-19", "Capricorn"), + ("2000-01-20", "Aquarius"), + ]) + def test_zodiac_boundary_days(self, ac, date_str, expected_sign): + assert ac.calculate_zodiac_sign(date_str)["sign"] == expected_sign + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_leap_day_birth_in_non_leap_current_year_does_not_crash(self, ac): + # EG / FAULT-HUNTING: Feb 29 birthday + non-leap current year. + # SPEC says calculate_age returns a full dict for any valid birth+ + # current pair. Expected to FAIL until leap-day edge is handled. + # See FINDINGS.md FAULT-004. + result = ac.calculate_age("2000-02-29", "2021-03-01") + assert result["years"] == 21 + + def test_ambiguous_date_dd_mm_wins_over_mm_dd(self, ac): + # EG: SPEC notes the DD/MM/YYYY format is tried before MM/DD/YYYY, so + # "03/04/2020" resolves to 3 April 2020, not 4 March 2020. This test + # documents the first-match contract. + assert ac.parse_date("03/04/2020") == datetime.date(2020, 4, 3) + + def test_chinese_zodiac_returns_animal_and_element(self, ac): + # EG: contract check - dict has both keys. + z = ac.calculate_chinese_zodiac("2000-01-01") + assert "animal" in z and "element" in z + + def test_calculate_zodiac_never_returns_unknown_for_valid_date(self, ac): + # EG / FAULT-HUNTING: SPEC §Gaps #6 flags that the zodiac function + # has an "Unknown" fallback that should never fire for a valid date. + # Iterate every day of a year and ensure no "Unknown" surfaces. + start = datetime.date(2000, 1, 1) + for offset in range(366): # 2000 is a leap year + d = start + datetime.timedelta(days=offset) + sign = ac.calculate_zodiac_sign(d)["sign"] + assert sign != "Unknown", f"zodiac returned 'Unknown' for {d}" + + def test_parse_date_empty_string_raises(self, ac): + # EG: empty input -> ValueError. + with pytest.raises(ValueError): + ac.parse_date("") + + def test_parse_date_whitespace_only_raises(self, ac): + # EG: whitespace-only input -> ValueError. + with pytest.raises(ValueError): + ac.parse_date(" ") + + def test_life_events_returns_eight_milestones(self, ac): + # EG: contract - 8 milestone ages per SPEC. + events = ac.calculate_life_events("2000-01-01") + assert len(events) == 8 + ages = [e["age"] for e in events] + assert ages == [18, 21, 25, 30, 40, 50, 65, 100] diff --git a/backend/tests/blackbox/utilities/test_password_generator.py b/backend/tests/blackbox/utilities/test_password_generator.py new file mode 100644 index 00000000..3ff61ece --- /dev/null +++ b/backend/tests/blackbox/utilities/test_password_generator.py @@ -0,0 +1,274 @@ +""" +Black-box tests for scripts.utilities.password_generator. + +Derived from SPEC.md §password_generator without peeking at implementation. +Applies Equivalence Partitioning (EP), Boundary Analysis (BA), and Error +Guessing (EG) per proposal §2.2. Each test case is labeled with the +technique and the specific goal it serves (rubric: "every test case must +have a clear justification"). +""" +import string + +import pytest + +from scripts.utilities.password_generator import PasswordGenerator + +pytestmark = pytest.mark.blackbox + + +@pytest.fixture +def gen(): + return PasswordGenerator() + + +AMBIGUOUS = set("0O1lI") +SYMBOLS = set("!@#$%^&*()_+-=[]{}|;:,.<>?") + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestGenerateRandomPasswordEP: + """ + EP partitions for generate_random_password: + length: valid (>=4) vs invalid (<4) + types: >=1 type enabled vs all disabled + ambiguous: exclude_ambiguous vs include_ambiguous + """ + + def test_valid_length_all_types_enabled_returns_correct_length(self, gen, fixed_secrets): + # EP: representative of "valid length + all char types enabled" class + pwd = gen.generate_random_password(length=12) + assert isinstance(pwd, str) + assert len(pwd) == 12 + + def test_invalid_length_below_min_raises(self, gen): + # EP: length<4 is the invalid class -> ValueError per SPEC. + with pytest.raises(ValueError): + gen.generate_random_password(length=3) + + def test_all_character_types_disabled_raises(self, gen): + # EP: empty char-type set is the invalid class -> ValueError per SPEC. + with pytest.raises(ValueError): + gen.generate_random_password( + length=8, + include_uppercase=False, + include_lowercase=False, + include_digits=False, + include_symbols=False, + ) + + def test_only_digits_enabled_contains_only_digits(self, gen, fixed_secrets): + # EP: valid subset (digits-only) -> output is all digits. + pwd = gen.generate_random_password( + length=16, + include_uppercase=False, + include_lowercase=False, + include_digits=True, + include_symbols=False, + ) + assert all(c in string.digits for c in pwd) + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestGenerateRandomPasswordBA: + """Boundary analysis around the length=4 lower bound of generate_random_password.""" + + def test_length_just_below_min_raises(self, gen): + # BA: length=3 (min-1) -> ValueError. + with pytest.raises(ValueError): + gen.generate_random_password(length=3) + + @pytest.mark.parametrize("length", [4, 5]) + def test_length_at_or_just_above_min_succeeds(self, gen, fixed_secrets, length): + # BA: length=4 (min) and length=5 (min+1) -> succeed with correct length. + pwd = gen.generate_random_password(length=length) + assert len(pwd) == length + + def test_large_length_succeeds(self, gen, fixed_secrets): + # BA: upper-side probe (no documented max) -> still works. + pwd = gen.generate_random_password(length=1024) + assert len(pwd) == 1024 + + +class TestGeneratePinBA: + """Boundary analysis for PIN length (lower bound = 1).""" + + def test_pin_length_zero_raises(self, gen): + # BA: length=0 (min-1) -> ValueError. + with pytest.raises(ValueError): + gen.generate_pin(length=0) + + def test_pin_length_one_is_single_digit(self, gen, fixed_secrets): + # BA: length=1 (min) -> exactly one digit char. + pin = gen.generate_pin(length=1) + assert len(pin) == 1 + assert pin in string.digits + + def test_pin_length_two_has_two_digits(self, gen, fixed_secrets): + # BA: length=2 (min+1) -> two digits, all numeric. + pin = gen.generate_pin(length=2) + assert len(pin) == 2 + assert pin.isdigit() + + +class TestGenerateHexPasswordBA: + """Boundary analysis for hex password (lower bound = 4).""" + + def test_hex_length_below_min_raises(self, gen): + # BA: length=3 (min-1) -> ValueError. + with pytest.raises(ValueError): + gen.generate_hex_password(length=3) + + def test_hex_length_at_min(self, gen, fixed_secrets): + # BA: length=4 (min) -> returns 4 lowercase hex chars. + pwd = gen.generate_hex_password(length=4) + assert len(pwd) == 4 + assert all(c in "0123456789abcdef" for c in pwd) + + +# ========================================================================= +# EG â Error Guessing (fault-prone cases) +# ========================================================================= + +class TestErrorGuessing: + """ + EG tests target fault-prone scenarios identified in SPEC §Gaps #6. + """ + + def test_exclude_ambiguous_never_emits_ambiguous_chars(self, gen): + # EG / FAULT-HUNTING: SPEC §Gaps #6 suspects that required_chars are + # sampled from the UNFILTERED character sets, which would mean the + # generator leaks {0, O, 1, l, I} even when exclude_ambiguous=True. + # Stress the code over many iterations so the leak path is likely hit. + # This test is intentionally designed to FAIL if the suspected fault + # exists (rubric: discover real faults). + offenders = set() + for _ in range(500): + pwd = gen.generate_random_password( + length=8, + include_uppercase=True, + include_lowercase=True, + include_digits=True, + include_symbols=False, + exclude_ambiguous=True, + ) + offenders |= AMBIGUOUS.intersection(pwd) + assert not offenders, f"ambiguous chars leaked: {offenders}" + + def test_custom_pattern_only_literals_returns_literals_verbatim(self, gen): + # EG: pattern of pure literals (no L/U/D/S/X) passes through unchanged. + assert gen.generate_custom_pattern("hello!") == "hello!" + + def test_custom_pattern_empty_returns_empty(self, gen): + # EG: empty pattern -> empty string (documents the boundary). + assert gen.generate_custom_pattern("") == "" + + def test_check_strength_empty_string_does_not_crash(self, gen): + # EG: empty password is a corner case. Must be handled without raising. + # Expected per SPEC: length=0, unique_chars=0, score=0/"Very Weak". + result = gen.check_password_strength("") + assert result["length"] == 0 + assert result["unique_chars"] == 0 + assert result["strength"] == "Very Weak" + + def test_check_strength_single_char(self, gen): + # EG: single-char password -> length=1, unique=1, score low. + result = gen.check_password_strength("a") + assert result["length"] == 1 + assert result["unique_chars"] == 1 + # length<8 => no length point, has lowercase => 1 pt for variety, etc. + assert result["score"] <= 3 + + def test_check_strength_includes_all_required_keys(self, gen): + # EG: contract check - the return dict must expose documented keys so + # downstream code doesn't break on missing fields. + result = gen.check_password_strength("Abcdef1!") + for key in ("score", "max_score", "strength", "feedback", "length", "unique_chars"): + assert key in result, f"missing key: {key}" + assert result["max_score"] == 8 + + def test_generate_pin_returns_only_digits(self, gen, fixed_secrets): + # EG: PIN must never emit non-digits, even across many iterations. + for _ in range(50): + pin = gen.generate_pin(length=6) + assert pin.isdigit() + assert len(pin) == 6 + + def test_passphrase_num_words_larger_than_pool_fallback_still_works(self, gen, fixed_secrets): + # EG: requesting a number of words the pool cannot satisfy triggers the + # fallback pool. Should not crash. + pwd = gen.generate_passphrase(num_words=20, min_length=1, max_length=2) + parts = pwd.split(' ') + assert len(parts) == 20 + + +# ========================================================================= +# EP â Other generators +# ========================================================================= + +class TestMemorableEP: + def test_memorable_default_uses_dash_separator(self, gen, fixed_secrets): + # EP: default separator class '-'. With 3 words -> exactly 2 separators + # (ignoring the 2 trailing digits which contain no '-'). + pwd = gen.generate_memorable_password(num_words=3, separator='-', include_numbers=False) + assert pwd.count('-') == 2 + + def test_memorable_include_numbers_appends_two_digits(self, gen, fixed_secrets): + # EP: include_numbers=True class -> exactly 2 trailing digits per SPEC. + pwd = gen.generate_memorable_password(num_words=2, include_numbers=True) + assert pwd[-2:].isdigit() + + def test_memorable_custom_separator(self, gen, fixed_secrets): + # EP: non-default separator class. 3 words -> 2 custom separators. + pwd = gen.generate_memorable_password(num_words=3, separator='_', include_numbers=False) + assert pwd.count('_') == 2 + + +class TestPassphraseEP: + def test_passphrase_joins_words_with_single_space(self, gen, fixed_secrets): + # EP: output contract - space-joined. + pwd = gen.generate_passphrase(num_words=4) + assert len(pwd.split(' ')) == 4 + + def test_passphrase_words_are_capitalized(self, gen, fixed_secrets): + # EP: each word capitalized per SPEC. + pwd = gen.generate_passphrase(num_words=4) + assert all(p[0].isupper() for p in pwd.split(' ')) + + +class TestCustomPatternEP: + @pytest.mark.parametrize("token, charset", [ + ('L', string.ascii_lowercase), + ('U', string.ascii_uppercase), + ('D', string.digits), + ]) + def test_pattern_single_token_yields_correct_charset(self, gen, fixed_secrets, token, charset): + # EP: each token class (L/U/D) produces one char from its set. + result = gen.generate_custom_pattern(token) + assert len(result) == 1 + assert result in charset + + def test_pattern_symbol_token_yields_symbol_char(self, gen, fixed_secrets): + # EP: 'S' token class -> one char from the symbol set. + result = gen.generate_custom_pattern("S") + assert len(result) == 1 + assert result in SYMBOLS + + def test_pattern_x_token_is_alphanumeric(self, gen, fixed_secrets): + # EP: 'X' token class -> one alphanumeric (no symbols). + result = gen.generate_custom_pattern("X") + assert len(result) == 1 + assert result in (string.ascii_letters + string.digits) + + def test_pattern_mixed_tokens_and_literals_preserves_order(self, gen, fixed_secrets): + # EP: mix of token and literal. Position of literal must be preserved. + result = gen.generate_custom_pattern("U-D") + assert len(result) == 3 + assert result[0] in string.ascii_uppercase + assert result[1] == '-' + assert result[2] in string.digits diff --git a/backend/tests/blackbox/utilities/test_unit_converter.py b/backend/tests/blackbox/utilities/test_unit_converter.py new file mode 100644 index 00000000..0e7561e9 --- /dev/null +++ b/backend/tests/blackbox/utilities/test_unit_converter.py @@ -0,0 +1,162 @@ +""" +Black-box tests for scripts.utilities.unit_converter. + +Derived from SPEC.md §unit_converter (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. Each test is labeled with technique. +""" +import math + +import pytest + +from scripts.utilities.unit_converter import UnitConverter + +pytestmark = pytest.mark.blackbox + + +@pytest.fixture +def uc(): + return UnitConverter() + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestConvertStandardEP: + """ + EP partitions for convert_standard: + category: known vs unknown + units: both in category vs either missing + from/to: same vs different + """ + + def test_known_category_known_units_returns_conversion(self, uc): + # EP: canonical valid class - 1000 m -> 1 km. + assert uc.convert_standard(1000, "m", "km", "length") == pytest.approx(1.0) + + def test_unknown_category_returns_none(self, uc): + # EP: unknown category -> None per SPEC. + assert uc.convert_standard(1, "m", "km", "fake_category") is None + + def test_unknown_from_unit_returns_none(self, uc): + # EP: valid category but unknown from_unit -> None. + assert uc.convert_standard(1, "banana", "km", "length") is None + + def test_unknown_to_unit_returns_none(self, uc): + # EP: valid category but unknown to_unit -> None. + assert uc.convert_standard(1, "m", "banana", "length") is None + + def test_same_unit_returns_input_value(self, uc): + # EP: from==to is identity class. + assert uc.convert_standard(42, "kg", "kg", "weight") == 42 + + +class TestConvertTemperatureEP: + """EP classes for temperature: celsius/fahrenheit/kelvin/rankine pairs.""" + + @pytest.mark.parametrize("value, f, t, expected", [ + (0, "celsius", "fahrenheit", 32), + (100, "celsius", "fahrenheit", 212), + (32, "fahrenheit", "celsius", 0), + (0, "celsius", "kelvin", 273.15), + (273.15, "kelvin", "celsius", 0), + ]) + def test_standard_conversions(self, uc, value, f, t, expected): + # EP: one representative per pair-of-units partition. + assert uc.convert_temperature(value, f, t) == pytest.approx(expected, abs=1e-6) + + def test_same_unit_returns_input(self, uc): + # EP: from==to identity class. + assert uc.convert_temperature(25, "celsius", "celsius") == 25 + + +class TestConvertDispatchEP: + """EP for the top-level convert(): category=None triggers auto-detect.""" + + def test_auto_detect_length(self, uc): + # EP: both units belong to length -> category inferred. + assert uc.convert(1, "km", "m") == pytest.approx(1000.0) + + def test_auto_detect_temperature(self, uc): + # EP: auto-detect routes through convert_temperature. + assert uc.convert(0, "celsius", "kelvin") == pytest.approx(273.15) + + def test_cross_category_units_returns_none(self, uc): + # EP: kg -> m is invalid class -> None per SPEC. + assert uc.convert(1, "kg", "m") is None + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_absolute_zero_celsius_to_kelvin(self, uc): + # BA: physical lower bound of temperature. + assert uc.convert(-273.15, "celsius", "kelvin") == pytest.approx(0, abs=1e-9) + + def test_absolute_zero_fahrenheit(self, uc): + # BA: -459.67 F == 0 K. + assert uc.convert(-459.67, "fahrenheit", "kelvin") == pytest.approx(0, abs=1e-4) + + def test_zero_length_conversion(self, uc): + # BA: 0 is a natural boundary value for multiplicative conversions. + assert uc.convert(0, "m", "km") == 0 + + def test_negative_length_value_still_converts(self, uc): + # BA: negative-side probe - convert is purely multiplicative. + assert uc.convert(-1000, "m", "km") == pytest.approx(-1.0) + + def test_very_small_value(self, uc): + # BA: below-1 region tests precision path. 0.000001 km == 1 mm. + assert uc.convert(0.000001, "km", "mm") == pytest.approx(1.0, rel=1e-6) + + def test_very_large_value(self, uc): + # BA: upper-side probe - petabyte conversion. + assert uc.convert(1, "pb", "b") == pytest.approx(1125899906842624.0) + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_case_insensitive_temperature_units(self, uc): + # EG: per SPEC, convert_temperature lowercases from_unit / to_unit. + assert uc.convert_temperature(0, "CELSIUS", "FAHRENHEIT") == pytest.approx(32) + + def test_calculate_ratio_unknown_unit_should_return_none(self, uc): + # EG / FAULT-HUNTING: SPEC §Gaps #6 says calculate_ratio uses .get(u, 0) + # so an unknown unit silently becomes 0 and returns 0.0 instead of None. + # Intended contract: unknown unit -> None. + # Designed to FAIL if the suspected fault is present. + result = uc.calculate_ratio(10, "bogus_unit", 5, "m", "length") + assert result is None + + def test_calculate_ratio_happy_path(self, uc): + # EG contrast: known-units ratio should compute as expected. + # 1000 m vs 1 km -> both equal 1000 m in base, ratio = 1.0. + assert uc.calculate_ratio(1000, "m", 1, "km", "length") == pytest.approx(1.0) + + def test_calculate_ratio_zero_divisor_returns_none(self, uc): + # EG: 0-denominator case - must not raise ZeroDivisionError. + assert uc.calculate_ratio(5, "m", 0, "m", "length") is None + + def test_list_units_unknown_category_returns_empty_list(self, uc): + # EG: unknown category -> empty list (not None) per SPEC. + assert uc.list_units("nonsense") == [] + + def test_detect_category_cross_category_returns_none(self, uc): + # EG: "kg" and "m" have no shared category. + assert uc.detect_category("kg", "m") is None + + def test_detect_category_returns_first_match(self, uc): + # EG: units that live in multiple categories - documents first-match. + # (In the current SPEC, each unit appears in only one category, so this + # just asserts the contract is deterministic.) + assert uc.detect_category("kg", "g") == "weight" + + def test_convert_with_non_numeric_value_raises(self, uc): + # EG: non-numeric value propagates the TypeError from multiplication. + with pytest.raises(TypeError): + uc.convert("not a number", "m", "km") From cade29522169381ecd120377f366a32a6feb068b Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Mon, 20 Apr 2026 22:05:21 -0400 Subject: [PATCH 6/7] test: add black-box tests for automation and productivity modules --- backend/tests/blackbox/automation/__init__.py | 0 .../automation/test_auto_email_sender.py | 194 +++++++++++++++++ .../automation/test_file_organizer.py | 156 ++++++++++++++ .../tests/blackbox/productivity/__init__.py | 0 .../productivity/test_reminder_system.py | 194 +++++++++++++++++ .../productivity/test_todo_manager.py | 199 ++++++++++++++++++ 6 files changed, 743 insertions(+) create mode 100644 backend/tests/blackbox/automation/__init__.py create mode 100644 backend/tests/blackbox/automation/test_auto_email_sender.py create mode 100644 backend/tests/blackbox/automation/test_file_organizer.py create mode 100644 backend/tests/blackbox/productivity/__init__.py create mode 100644 backend/tests/blackbox/productivity/test_reminder_system.py create mode 100644 backend/tests/blackbox/productivity/test_todo_manager.py diff --git a/backend/tests/blackbox/automation/__init__.py b/backend/tests/blackbox/automation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/automation/test_auto_email_sender.py b/backend/tests/blackbox/automation/test_auto_email_sender.py new file mode 100644 index 00000000..22cf75ae --- /dev/null +++ b/backend/tests/blackbox/automation/test_auto_email_sender.py @@ -0,0 +1,194 @@ +""" +Black-box tests for scripts.automation.auto_email_sender. + +Derived from SPEC.md §auto_email_sender (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. + +This is the **SMTP mock target** (SPEC + PLAN). All tests patch smtplib.SMTP +so no real SMTP handshake happens. +""" +import json +import smtplib +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.automation.auto_email_sender import EmailSender + +pytestmark = pytest.mark.blackbox + + +DEFAULT_CONFIG = { + "smtp_server": "smtp.example.com", + "smtp_port": 587, + "sender_email": "me@example.com", + "sender_password": "secret", +} + + +@pytest.fixture +def sender(tmp_path, monkeypatch): + # Isolate the default "email_config.json" load by redirecting cwd. + monkeypatch.chdir(tmp_path) + cfg_file = tmp_path / "email_config.json" + cfg_file.write_text(json.dumps(DEFAULT_CONFIG)) + return EmailSender(config_file=str(cfg_file)) + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestLoadConfigEP: + def test_existing_config_file_loaded(self, tmp_path): + # EP: config file exists -> contents returned. + f = tmp_path / "c.json" + f.write_text(json.dumps({"smtp_server": "x.example", "smtp_port": 25, + "sender_email": "a@a", "sender_password": "p"})) + s = EmailSender(config_file=str(f)) + assert s.config["smtp_server"] == "x.example" + + def test_missing_config_file_uses_defaults(self, tmp_path): + # EP: config file missing -> default gmail dict per SPEC. + s = EmailSender(config_file=str(tmp_path / "nope.json")) + assert s.config["smtp_server"] == "smtp.gmail.com" + assert s.config["smtp_port"] == 587 + assert s.config["sender_email"] == "" + + +class TestSendEmailEP: + """ + EP partitions for send_email: + result: success -> True + SMTP exception: auth / recipients-refused / connect -> False + attachments: None / valid-path / missing-path + """ + + def test_success_returns_true_and_calls_smtp_in_order(self, sender): + # EP: happy path. Must return True and call SMTP methods in order. + with patch("smtplib.SMTP") as mock_smtp: + instance = mock_smtp.return_value + result = sender.send_email("to@x.com", "s", "b") + assert result is True + mock_smtp.assert_called_once_with("smtp.example.com", 587) + instance.starttls.assert_called_once() + instance.login.assert_called_once_with("me@example.com", "secret") + instance.sendmail.assert_called_once() + instance.quit.assert_called_once() + + def test_auth_error_returns_false(self, sender): + # EP: SMTPAuthenticationError class -> False (swallowed per SPEC). + with patch("smtplib.SMTP") as mock_smtp: + mock_smtp.return_value.login.side_effect = smtplib.SMTPAuthenticationError(535, b"bad creds") + result = sender.send_email("to@x.com", "s", "b") + assert result is False + + def test_recipients_refused_returns_false(self, sender): + # EP: SMTPRecipientsRefused class -> False. + with patch("smtplib.SMTP") as mock_smtp: + mock_smtp.return_value.sendmail.side_effect = smtplib.SMTPRecipientsRefused({"to@x.com": (550, b"no")}) + result = sender.send_email("to@x.com", "s", "b") + assert result is False + + def test_connect_failure_returns_false(self, sender): + # EP: connection class - SMTP() raises. + with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("down")): + result = sender.send_email("to@x.com", "s", "b") + assert result is False + + +class TestBulkEmailsEP: + def test_bulk_empty_list_never_calls_smtp(self, sender, capsys): + # EP: empty-list class - no smtplib call. + with patch("smtplib.SMTP") as mock_smtp: + sender.send_bulk_emails([], "s", "b") + mock_smtp.assert_not_called() + assert "0/0" in capsys.readouterr().out + + def test_bulk_all_succeed(self, sender, capsys): + # EP: all-success class - N/N in output. + with patch("smtplib.SMTP"): + sender.send_bulk_emails(["a@a", "b@b", "c@c"], "s", "b") + assert "3/3" in capsys.readouterr().out + + def test_bulk_partial_failure(self, sender, capsys): + # EP: partial-failure class - success count < total. + call_count = {"n": 0} + + def _side_effect(server, port): + call_count["n"] += 1 + instance = MagicMock() + if call_count["n"] == 2: + instance.sendmail.side_effect = smtplib.SMTPException("fail") + return instance + + with patch("smtplib.SMTP", side_effect=_side_effect): + sender.send_bulk_emails(["a@a", "b@b", "c@c"], "s", "b") + assert "2/3" in capsys.readouterr().out + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_single_recipient_bulk(self, sender, capsys): + # BA: smallest non-empty recipient list. + with patch("smtplib.SMTP"): + sender.send_bulk_emails(["one@x.com"], "s", "b") + assert "1/1" in capsys.readouterr().out + + def test_empty_subject_and_body_still_sent(self, sender): + # BA: empty strings are a natural lower bound. + with patch("smtplib.SMTP") as mock_smtp: + result = sender.send_email("to@x.com", "", "") + assert result is True + mock_smtp.return_value.sendmail.assert_called_once() + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_attachment_missing_path_is_silently_skipped(self, sender): + # EG / FAULT-HUNTING: SPEC §Gaps #6 flags that missing attachment paths + # are silently skipped (line 36 `if os.path.exists(file_path)`), so the + # email sends WITHOUT the expected attachment. Users likely expect + # either an error or a warning. This test documents the current + # behavior: no error, no crash, email still sent. + with patch("smtplib.SMTP") as mock_smtp: + result = sender.send_email( + "to@x.com", "s", "b", attachments=["/tmp/does_not_exist_xyz.pdf"] + ) + # Currently: True (email sent, attachment silently dropped). + # This is arguably a fault; see FINDINGS.md FAULT-005. + assert result is True + mock_smtp.return_value.sendmail.assert_called_once() + + def test_attachment_existing_path_is_included(self, sender, tmp_path): + # EG: attachment path that exists -> included in the MIME payload. + attach = tmp_path / "a.txt" + attach.write_text("hello") + with patch("smtplib.SMTP") as mock_smtp: + sender.send_email("to@x.com", "s", "b", attachments=[str(attach)]) + # Confirm sendmail received a message containing the attachment filename. + sent_payload = mock_smtp.return_value.sendmail.call_args.args[2] + assert "a.txt" in sent_payload + + def test_empty_recipient_still_calls_smtp(self, sender): + # EG: empty string recipient - MIME accepts; sendmail called per SPEC. + with patch("smtplib.SMTP") as mock_smtp: + result = sender.send_email("", "s", "b") + assert result is True + mock_smtp.return_value.sendmail.assert_called_once() + + def test_config_missing_required_key_returns_false(self, tmp_path): + # EG: malformed config (missing sender_email) triggers KeyError, which + # is caught by the generic except Exception -> returns False. + f = tmp_path / "bad.json" + f.write_text(json.dumps({"smtp_server": "x", "smtp_port": 25})) + s = EmailSender(config_file=str(f)) + with patch("smtplib.SMTP"): + result = s.send_email("to@x.com", "s", "b") + assert result is False diff --git a/backend/tests/blackbox/automation/test_file_organizer.py b/backend/tests/blackbox/automation/test_file_organizer.py new file mode 100644 index 00000000..fbfa884e --- /dev/null +++ b/backend/tests/blackbox/automation/test_file_organizer.py @@ -0,0 +1,156 @@ +""" +Black-box tests for scripts.automation.file_organizer. + +Derived from SPEC.md §file_organizer (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. Uses tmp_path for real +filesystem isolation (no mocks). +""" +import os + +import pytest + +from scripts.automation.file_organizer import ( + organize_files_by_date, + organize_files_by_extension, +) + +pytestmark = pytest.mark.blackbox + + +def _touch(dirpath, name, content="x"): + p = dirpath / name + p.write_text(content) + return p + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestOrganizeByExtensionEP: + """ + EP partitions for organize_files_by_extension: + file type: regular file / subdirectory + extension: known / no extension / mixed case / multi-dot + source: exists / does not exist + """ + + def test_single_extension_group(self, tmp_path): + # EP: one file with a standard extension -> moved into `/`. + _touch(tmp_path, "a.txt") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "txt" / "a.txt").exists() + assert not (tmp_path / "a.txt").exists() + + def test_multiple_extensions_get_separate_folders(self, tmp_path): + # EP: one file per extension class -> one folder per class. + _touch(tmp_path, "a.txt") + _touch(tmp_path, "b.csv") + _touch(tmp_path, "c.json") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "txt" / "a.txt").exists() + assert (tmp_path / "csv" / "b.csv").exists() + assert (tmp_path / "json" / "c.json").exists() + + def test_file_without_extension_goes_to_no_extension_folder(self, tmp_path): + # EP: no-extension class per SPEC. + _touch(tmp_path, "README") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "no_extension" / "README").exists() + + def test_subdirectories_are_left_alone(self, tmp_path): + # EP: non-file class (subdir) must not be organized. + (tmp_path / "sub").mkdir() + _touch(tmp_path, "x.txt") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "sub").is_dir() + assert (tmp_path / "txt" / "x.txt").exists() + + def test_nonexistent_source_is_silent(self, tmp_path, capsys): + # EP: invalid-source class -> prints and returns, no raise. + organize_files_by_extension(str(tmp_path / "nope")) + out = capsys.readouterr().out + assert "does not exist" in out + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_empty_directory(self, tmp_path): + # BA: empty collection boundary - nothing moved, no folders created. + organize_files_by_extension(str(tmp_path)) + # No new folders should have been created. + assert list(tmp_path.iterdir()) == [] + + def test_single_character_extension(self, tmp_path): + # BA: shortest legal extension (1 char). + _touch(tmp_path, "x.a") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "a" / "x.a").exists() + + def test_same_name_no_collision_when_unique(self, tmp_path): + # BA: multiple files of same extension with unique names. + _touch(tmp_path, "a.txt") + _touch(tmp_path, "b.txt") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "txt" / "a.txt").exists() + assert (tmp_path / "txt" / "b.txt").exists() + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_multi_dot_filename_uses_last_suffix_only(self, tmp_path): + # EG: SPEC documents multi-dot behavior: `a.tar.gz` -> suffix == `.gz`. + _touch(tmp_path, "archive.tar.gz") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "gz" / "archive.tar.gz").exists() + assert not (tmp_path / "tar.gz").exists() + + def test_mixed_case_extension_normalized_lowercase(self, tmp_path): + # EG: SPEC says .lower() is applied - uppercase extension goes to + # the lowercase-named folder. + _touch(tmp_path, "image.JPG") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "jpg" / "image.JPG").exists() + + def test_hidden_dotfile_has_no_extension_per_pathlib(self, tmp_path): + # EG: SPEC documents that `.bashrc` -> suffix == "" -> no_extension/. + _touch(tmp_path, ".bashrc") + organize_files_by_extension(str(tmp_path)) + assert (tmp_path / "no_extension" / ".bashrc").exists() + + def test_collision_on_existing_destination_preserves_existing_file(self, tmp_path): + # EG / FAULT-HUNTING: when `/` already exists at the + # destination, the expected safe behavior is to preserve the existing + # file (either skip, or rename the incoming). On some platforms + # `shutil.move` silently OVERWRITES the existing file, causing + # data loss. Intended contract: existing file is NOT overwritten. + # Designed to FAIL on platforms where shutil.move overwrites + # (see FINDINGS.md FAULT-006). + (tmp_path / "txt").mkdir() + existing = tmp_path / "txt" / "a.txt" + existing.write_text("EXISTING_DATA") + _touch(tmp_path, "a.txt", content="NEW_DATA") + organize_files_by_extension(str(tmp_path)) + # The existing file's content must not have been overwritten. + assert existing.read_text() == "EXISTING_DATA" + + def test_organize_by_date_creates_year_month_folder(self, tmp_path): + # EG: by_date path creates a YYYY-MM folder derived from ctime. + _touch(tmp_path, "f.log") + organize_files_by_date(str(tmp_path)) + # Exactly one new folder should exist with a YYYY-MM name. + subdirs = [p for p in tmp_path.iterdir() if p.is_dir()] + assert len(subdirs) == 1 + assert len(subdirs[0].name) == 7 + assert subdirs[0].name[4] == "-" + + def test_organize_by_date_nonexistent_source_is_silent(self, tmp_path, capsys): + # EG: same invalid-source contract as by_extension. + organize_files_by_date(str(tmp_path / "nope")) + assert "does not exist" in capsys.readouterr().out diff --git a/backend/tests/blackbox/productivity/__init__.py b/backend/tests/blackbox/productivity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/productivity/test_reminder_system.py b/backend/tests/blackbox/productivity/test_reminder_system.py new file mode 100644 index 00000000..0e160518 --- /dev/null +++ b/backend/tests/blackbox/productivity/test_reminder_system.py @@ -0,0 +1,194 @@ +""" +Black-box tests for scripts.productivity.reminder_system. + +Derived from SPEC.md §reminder_system (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. +""" +import datetime +import json + +import pytest + +from scripts.productivity.reminder_system import Reminder, ReminderManager + +pytestmark = pytest.mark.blackbox + + +def _mgr(tmp_path): + return ReminderManager(filename=str(tmp_path / "reminders.json")) + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestAddRemoveEP: + """EP for add/remove operations.""" + + def test_add_reminder_returns_id_and_stores(self, tmp_path): + # EP: add one -> id returned, reminder persisted. + mgr = _mgr(tmp_path) + now = datetime.datetime(2030, 1, 1, 10, 0) + rid = mgr.add_reminder("take meds", now) + assert isinstance(rid, str) + assert len(mgr.reminders) == 1 + + def test_remove_existing_id(self, tmp_path): + # EP: remove existing id -> reminder list shrinks. + mgr = _mgr(tmp_path) + rid = mgr.add_reminder("x", datetime.datetime(2030, 1, 1)) + mgr.remove_reminder(rid) + assert mgr.reminders == [] + + def test_remove_nonexistent_id_is_silent(self, tmp_path): + # EP: remove non-existent id -> no change, no raise per SPEC. + mgr = _mgr(tmp_path) + mgr.add_reminder("x", datetime.datetime(2030, 1, 1)) + mgr.remove_reminder("this-id-does-not-exist") + assert len(mgr.reminders) == 1 + + +class TestCalculateNextTimeEP: + """EP for calculate_next_time: m / h / d suffix classes + unknown fallback.""" + + @pytest.mark.parametrize("interval, delta", [ + ("30m", datetime.timedelta(minutes=30)), + ("2h", datetime.timedelta(hours=2)), + ("1d", datetime.timedelta(days=1)), + ]) + def test_known_suffixes(self, tmp_path, interval, delta): + # EP: one representative per suffix class. + mgr = _mgr(tmp_path) + t0 = datetime.datetime(2030, 1, 1, 0, 0) + assert mgr.calculate_next_time(t0, interval) == t0 + delta + + def test_unknown_suffix_fallback_to_one_hour(self, tmp_path): + # EP: unknown-suffix class -> falls back to +1h per SPEC. + mgr = _mgr(tmp_path) + t0 = datetime.datetime(2030, 1, 1, 0, 0) + result = mgr.calculate_next_time(t0, "bogus_interval") + assert result == t0 + datetime.timedelta(hours=1) + + +class TestParseTimeStringEP: + def test_iso_timestamp_parsed(self, tmp_path): + # EP: ISO format (contains 'T') routed through fromisoformat. + mgr = _mgr(tmp_path) + result = mgr.parse_time_string("2030-01-01T10:00:00") + assert result == datetime.datetime(2030, 1, 1, 10, 0, 0) + + def test_invalid_format_returns_none(self, tmp_path): + # EP: invalid-format class -> None per SPEC. + mgr = _mgr(tmp_path) + assert mgr.parse_time_string("not a time") is None + + def test_hh_mm_in_future_returns_today(self, tmp_path, frozen_time): + # EP: HH:MM > now -> today at that time. frozen_time = 2026-04-19 12:00. + mgr = _mgr(tmp_path) + result = mgr.parse_time_string("15:30") + assert result is not None + assert result.hour == 15 and result.minute == 30 + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_calculate_next_time_zero_minutes(self, tmp_path): + # BA: 0m = identity (delta = 0). + mgr = _mgr(tmp_path) + t0 = datetime.datetime(2030, 1, 1) + assert mgr.calculate_next_time(t0, "0m") == t0 + + def test_check_reminders_at_exact_trigger_time(self, tmp_path, monkeypatch): + # BA: reminder_time == now (boundary - should trigger per SPEC '<='). + mgr = _mgr(tmp_path) + fixed_now = datetime.datetime(2030, 1, 1, 10, 0, 0) + + class _DT(datetime.datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("scripts.productivity.reminder_system.datetime.datetime", _DT) + mgr.add_reminder("exact", fixed_now) + mgr.check_reminders() + # Non-repeating reminder -> active=False after trigger. + assert mgr.reminders[0].active is False + + def test_check_reminders_one_second_before_trigger(self, tmp_path, monkeypatch): + # BA: reminder_time > now by 1 second -> not triggered. + mgr = _mgr(tmp_path) + fixed_now = datetime.datetime(2030, 1, 1, 10, 0, 0) + mgr.add_reminder("future", fixed_now + datetime.timedelta(seconds=1)) + + class _DT(datetime.datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("scripts.productivity.reminder_system.datetime.datetime", _DT) + mgr.check_reminders() + assert mgr.reminders[0].active is True + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_repeat_interval_advances_reminder_time(self, tmp_path, monkeypatch): + # EG: a repeating reminder must advance by its interval after firing. + mgr = _mgr(tmp_path) + fire_at = datetime.datetime(2030, 1, 1, 10, 0, 0) + mgr.add_reminder("repeat", fire_at, repeat=True, repeat_interval="30m") + + class _DT(datetime.datetime): + @classmethod + def now(cls, tz=None): + return fire_at + + monkeypatch.setattr("scripts.productivity.reminder_system.datetime.datetime", _DT) + mgr.check_reminders() + # Still active; reminder_time pushed forward by 30 minutes. + assert mgr.reminders[0].active is True + assert mgr.reminders[0].reminder_time == fire_at + datetime.timedelta(minutes=30) + + def test_load_missing_file_returns_empty(self, tmp_path): + # EG: no file -> empty list, not a crash. + mgr = ReminderManager(filename=str(tmp_path / "no_such_file.json")) + assert mgr.reminders == [] + + def test_round_trip_persistence(self, tmp_path): + # EG: save + reload -> same data back. + filepath = str(tmp_path / "rt.json") + mgr = ReminderManager(filename=filepath) + fire = datetime.datetime(2030, 6, 15, 8, 30) + mgr.add_reminder("do X", fire) + mgr2 = ReminderManager(filename=filepath) + assert len(mgr2.reminders) == 1 + assert mgr2.reminders[0].message == "do X" + assert mgr2.reminders[0].reminder_time == fire + + def test_id_collision_when_created_within_same_millisecond(self, tmp_path, monkeypatch): + # EG / FAULT-HUNTING: SPEC §Gaps #6 warns that ID = int(time.time()*1000). + # Two reminders created in the same ms share an ID. Demonstrates the + # risk by freezing time.time(). + mgr = _mgr(tmp_path) + monkeypatch.setattr("scripts.productivity.reminder_system.time.time", lambda: 1700000000.0) + id1 = mgr.add_reminder("a", datetime.datetime(2030, 1, 1)) + id2 = mgr.add_reminder("b", datetime.datetime(2030, 1, 2)) + # Documenting the collision: same timestamp -> same id. + assert id1 == id2 + + def test_empty_message_accepted(self, tmp_path): + # EG: empty message - no validation documented -> accepted. + mgr = _mgr(tmp_path) + rid = mgr.add_reminder("", datetime.datetime(2030, 1, 1)) + assert rid in [r.id for r in mgr.reminders] + + def test_parse_time_string_whitespace_returns_none(self, tmp_path): + # EG: whitespace-only string -> None (no crash). + mgr = _mgr(tmp_path) + assert mgr.parse_time_string(" ") is None diff --git a/backend/tests/blackbox/productivity/test_todo_manager.py b/backend/tests/blackbox/productivity/test_todo_manager.py new file mode 100644 index 00000000..fa72a1a9 --- /dev/null +++ b/backend/tests/blackbox/productivity/test_todo_manager.py @@ -0,0 +1,199 @@ +""" +Black-box tests for scripts.productivity.todo_manager. + +Derived from SPEC.md §todo_manager (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. + +These tests treat the JSON-backed persistence as an external boundary +and use `tmp_json_store` (cwd redirect) so they never touch the real +todo_list.json at the repo root. +""" +import datetime +import json +import os + +import pytest + +from scripts.productivity.todo_manager import Priority, TodoItem, TodoManager + +pytestmark = pytest.mark.blackbox + + +def _mgr(tmp_path): + # Always instantiate AFTER cwd is redirected so default filename lands in tmp. + return TodoManager(filename=str(tmp_path / "todo_list.json")) + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestAddTaskEP: + """ + EP partitions for add_task: + priority: LOW / MEDIUM / HIGH (3 valid classes) + due_date: None / ISO-date-string + """ + + def test_add_task_default_priority(self, tmp_path): + # EP: default priority class -> MEDIUM. + mgr = _mgr(tmp_path) + mgr.add_task("buy milk") + assert len(mgr.todos) == 1 + assert mgr.todos[0].priority == Priority.MEDIUM + + @pytest.mark.parametrize("prio", [Priority.LOW, Priority.MEDIUM, Priority.HIGH]) + def test_add_task_each_priority(self, tmp_path, prio): + # EP: one test per priority class. + mgr = _mgr(tmp_path) + mgr.add_task("x", priority=prio) + assert mgr.todos[0].priority == prio + + def test_add_task_persists_to_disk(self, tmp_path): + # EP: side effect - the JSON file must exist and contain the task. + mgr = _mgr(tmp_path) + mgr.add_task("persist me") + filepath = tmp_path / "todo_list.json" + assert filepath.exists() + data = json.loads(filepath.read_text()) + assert data[0]["task"] == "persist me" + + +class TestCompleteTaskEP: + def test_complete_task_marks_completed(self, tmp_path): + # EP: valid-index class. + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.complete_task(0) + assert mgr.todos[0].completed is True + + def test_complete_invalid_index_is_silent(self, tmp_path, capsys): + # EP: invalid-index class - no raise, prints "Invalid task index". + mgr = _mgr(tmp_path) + mgr.add_task("only one") + mgr.complete_task(5) + out = capsys.readouterr().out + assert "Invalid" in out + assert mgr.todos[0].completed is False + + +class TestRemoveTaskEP: + def test_remove_valid_index(self, tmp_path): + # EP: valid-index class. + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.add_task("b") + mgr.remove_task(0) + assert [t.task for t in mgr.todos] == ["b"] + + def test_remove_invalid_index_is_silent(self, tmp_path, capsys): + # EP: invalid-index class. + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.remove_task(99) + out = capsys.readouterr().out + assert "Invalid" in out + assert len(mgr.todos) == 1 + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestIndexBoundariesBA: + """Boundary analysis on task indices: -1, 0, len-1, len.""" + + def test_negative_one_is_invalid(self, tmp_path, capsys): + # BA: index=-1 (one below valid range). + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.complete_task(-1) + assert "Invalid" in capsys.readouterr().out + assert mgr.todos[0].completed is False + + def test_index_zero_is_valid_when_list_nonempty(self, tmp_path): + # BA: index=0 (lower valid bound). + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.complete_task(0) + assert mgr.todos[0].completed is True + + def test_index_equal_to_length_is_invalid(self, tmp_path, capsys): + # BA: index==len (first out-of-bounds value above). + mgr = _mgr(tmp_path) + mgr.add_task("a") + mgr.complete_task(1) + assert "Invalid" in capsys.readouterr().out + + def test_complete_on_empty_list_is_silent(self, tmp_path, capsys): + # BA: index=0 when list is empty (len=0 -> no valid index). + mgr = _mgr(tmp_path) + mgr.complete_task(0) + assert "Invalid" in capsys.readouterr().out + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_duplicate_task_text_allowed(self, tmp_path): + # EG: SPEC says no uniqueness constraint. Duplicates should coexist. + mgr = _mgr(tmp_path) + mgr.add_task("duplicate") + mgr.add_task("duplicate") + assert len(mgr.todos) == 2 + + def test_empty_task_text_allowed(self, tmp_path): + # EG: empty string task text. No validation documented -> accepted. + mgr = _mgr(tmp_path) + mgr.add_task("") + assert mgr.todos[0].task == "" + + def test_get_today_tasks_empty_when_no_due_dates(self, tmp_path): + # EG: tasks without due_date are not "today" tasks. + mgr = _mgr(tmp_path) + mgr.add_task("no due") + assert mgr.get_today_tasks() == [] + + def test_get_today_tasks_returns_tasks_with_today_due(self, tmp_path): + # EG: due_date matching today's ISO date -> in result. + mgr = _mgr(tmp_path) + today = datetime.date.today().isoformat() + mgr.add_task("today", due_date=today) + mgr.add_task("not today", due_date="1999-01-01") + today_tasks = mgr.get_today_tasks() + assert len(today_tasks) == 1 + assert today_tasks[0].task == "today" + + def test_completed_today_tasks_excluded(self, tmp_path): + # EG: completed tasks are filtered out of today's list. + mgr = _mgr(tmp_path) + today = datetime.date.today().isoformat() + mgr.add_task("done", due_date=today) + mgr.complete_task(0) + assert mgr.get_today_tasks() == [] + + def test_load_todos_on_missing_file_returns_empty(self, tmp_path): + # EG: no existing file -> empty list, not a crash. + mgr = TodoManager(filename=str(tmp_path / "does_not_exist.json")) + assert mgr.todos == [] + + def test_load_todos_round_trip_preserves_fields(self, tmp_path): + # EG: save/reload contract - priority, task, completed, due_date preserved. + filepath = str(tmp_path / "rt.json") + mgr = TodoManager(filename=filepath) + mgr.add_task("round trip", priority=Priority.HIGH, due_date="2030-01-01") + mgr.complete_task(0) + mgr2 = TodoManager(filename=filepath) + assert mgr2.todos[0].task == "round trip" + assert mgr2.todos[0].priority == Priority.HIGH + assert mgr2.todos[0].completed is True + assert mgr2.todos[0].due_date == "2030-01-01" + + def test_load_corrupt_json_raises(self, tmp_path): + # EG: SPEC notes load_todos has no try/except - malformed JSON crashes. + filepath = tmp_path / "bad.json" + filepath.write_text("this is not json {") + with pytest.raises(json.JSONDecodeError): + TodoManager(filename=str(filepath)) From 2c46354dd088033e21c0f36169b91fdba5a926a4 Mon Sep 17 00:00:00 2001 From: JoannaCCJH Date: Mon, 20 Apr 2026 22:05:33 -0400 Subject: [PATCH 7/7] test: add black-box tests for auth router, data_tools, and web_scraping --- backend/tests/blackbox/data_tools/__init__.py | 0 .../data_tools/test_data_converter.py | 222 +++++++++++++++++ backend/tests/blackbox/routers/__init__.py | 0 .../blackbox/routers/test_auth_router.py | 149 ++++++++++++ .../tests/blackbox/web_scraping/__init__.py | 0 .../web_scraping/test_weather_checker.py | 225 ++++++++++++++++++ 6 files changed, 596 insertions(+) create mode 100644 backend/tests/blackbox/data_tools/__init__.py create mode 100644 backend/tests/blackbox/data_tools/test_data_converter.py create mode 100644 backend/tests/blackbox/routers/__init__.py create mode 100644 backend/tests/blackbox/routers/test_auth_router.py create mode 100644 backend/tests/blackbox/web_scraping/__init__.py create mode 100644 backend/tests/blackbox/web_scraping/test_weather_checker.py diff --git a/backend/tests/blackbox/data_tools/__init__.py b/backend/tests/blackbox/data_tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/data_tools/test_data_converter.py b/backend/tests/blackbox/data_tools/test_data_converter.py new file mode 100644 index 00000000..412eb2f9 --- /dev/null +++ b/backend/tests/blackbox/data_tools/test_data_converter.py @@ -0,0 +1,222 @@ +""" +Black-box tests for scripts.data_tools.data_converter. + +Derived from SPEC.md §data_converter (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. + +Uses tmp_path for real file IO (no mocks - pandas-on-disk is part of +the contract under test). +""" +import json +import os + +import pytest + +from scripts.data_tools.data_converter import DataConverter + +pytestmark = pytest.mark.blackbox + + +@pytest.fixture +def dc(): + return DataConverter() + + +SAMPLE_RECORDS = [ + {"id": 1, "name": "A", "score": 88.5}, + {"id": 2, "name": "B", "score": 92.0}, + {"id": 3, "name": "C", "score": 77.0}, +] + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestAutoReadEP: + """EP per file extension class.""" + + def test_auto_read_json(self, dc, tmp_path): + # EP: .json extension -> read_json path. + f = tmp_path / "x.json" + f.write_text(json.dumps(SAMPLE_RECORDS)) + data = dc.auto_read(str(f)) + assert len(data) == 3 + + def test_auto_read_csv(self, dc, tmp_path): + # EP: .csv extension. + f = tmp_path / "x.csv" + f.write_text("a,b\n1,2\n3,4\n") + data = dc.auto_read(str(f)) + assert list(data.columns) == ["a", "b"] + + def test_auto_read_txt(self, dc, tmp_path): + # EP: .txt extension -> raw file content. + f = tmp_path / "x.txt" + f.write_text("hello world") + assert dc.auto_read(str(f)) == "hello world" + + def test_auto_read_unknown_extension_returns_none(self, dc, tmp_path): + # EP: unsupported extension class. + f = tmp_path / "x.foo" + f.write_text("content") + assert dc.auto_read(str(f)) is None + + def test_auto_read_case_insensitive_extension(self, dc, tmp_path): + # EP: SPEC says dispatch is case-insensitive. + f = tmp_path / "x.JSON" + f.write_text(json.dumps({"k": "v"})) + assert dc.auto_read(str(f)) == {"k": "v"} + + +class TestValidateJsonEP: + def test_valid_json_returns_true(self, dc, tmp_path): + # EP: valid json class. + f = tmp_path / "v.json" + f.write_text('{"k": 1}') + ok, msg = dc.validate_json(str(f)) + assert ok is True + + def test_invalid_json_returns_false(self, dc, tmp_path): + # EP: invalid json class. + f = tmp_path / "bad.json" + f.write_text("{not: valid}") + ok, msg = dc.validate_json(str(f)) + assert ok is False + assert "Invalid" in msg + + def test_missing_file_returns_false(self, dc, tmp_path): + # EP: missing file class. + ok, msg = dc.validate_json(str(tmp_path / "nope.json")) + assert ok is False + + +class TestValidateCsvEP: + def test_empty_csv_is_invalid(self, dc, tmp_path): + # EP: empty-file class. + f = tmp_path / "empty.csv" + f.write_text("") + ok, msg = dc.validate_csv(str(f)) + assert ok is False + + def test_csv_with_expected_columns_present(self, dc, tmp_path): + # EP: expected-columns match class. + f = tmp_path / "ok.csv" + f.write_text("a,b\n1,2\n") + ok, msg = dc.validate_csv(str(f), expected_columns=["a", "b"]) + assert ok is True + + def test_csv_with_missing_column(self, dc, tmp_path): + # EP: missing-expected-column class. + f = tmp_path / "miss.csv" + f.write_text("a\n1\n") + ok, msg = dc.validate_csv(str(f), expected_columns=["a", "b"]) + assert ok is False + assert "Missing" in msg + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_empty_list_json_write_and_read(self, dc, tmp_path): + # BA: empty collection boundary. + import pandas as pd + f = tmp_path / "empty.json" + assert dc.write_json([], str(f)) is True + data = dc.read_json(str(f)) + # Per SPEC, list-of-dicts returns a DataFrame; empty list may land as + # either an empty list or an empty DataFrame. + if isinstance(data, pd.DataFrame): + assert data.empty + else: + assert data == [] + + def test_single_record_round_trip(self, dc, tmp_path): + # BA: minimum non-empty dataset (1 record). + f = tmp_path / "one.json" + assert dc.write_json([{"k": 1}], str(f)) is True + data = dc.read_json(str(f)) + assert len(data) == 1 + + def test_flatten_empty_dict(self, dc): + # BA: empty dict -> empty dict (identity-ish). + assert dc.flatten_json({}) == {} + + def test_unflatten_empty_dict(self, dc): + # BA: empty input -> empty output. + assert dc.unflatten_json({}) == {} + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_flatten_nested_dict(self, dc): + # EG: nested dict flattens with dot-joined keys. + nested = {"a": {"b": {"c": 1}}} + assert dc.flatten_json(nested) == {"a.b.c": 1} + + def test_unflatten_round_trip_for_nested_dict(self, dc): + # EG: flatten then unflatten must restore the original structure. + nested = {"a": {"b": 1, "c": {"d": 2}}} + assert dc.unflatten_json(dc.flatten_json(nested)) == nested + + def test_flatten_list_becomes_json_string(self, dc): + # EG / contract: SPEC warns flatten encodes lists as json strings. + result = dc.flatten_json({"k": [1, 2, 3]}) + assert result == {"k": "[1, 2, 3]"} + + def test_convert_file_json_to_csv(self, dc, tmp_path): + # EG: JSON list-of-dicts -> CSV should succeed. + src = tmp_path / "src.json" + dst = tmp_path / "dst.csv" + src.write_text(json.dumps(SAMPLE_RECORDS)) + assert dc.convert_file(str(src), str(dst)) is True + assert dst.exists() + + def test_convert_file_scalar_json_to_csv_fails_gracefully(self, dc, tmp_path): + # EG: JSON scalar -> CSV is non-tabular and must return False, not crash. + src = tmp_path / "scalar.json" + dst = tmp_path / "out.csv" + src.write_text('"just a string"') + assert dc.convert_file(str(src), str(dst)) is False + + def test_convert_file_unknown_output_extension_returns_false(self, dc, tmp_path): + # EG: unknown output extension -> False per SPEC. + src = tmp_path / "s.json" + src.write_text(json.dumps(SAMPLE_RECORDS)) + dst = tmp_path / "out.foo" + assert dc.convert_file(str(src), str(dst)) is False + + def test_compare_identical_files_equal(self, dc, tmp_path): + # EG: two files with identical contents -> equal. + a = tmp_path / "a.json" + b = tmp_path / "b.json" + a.write_text(json.dumps(SAMPLE_RECORDS)) + b.write_text(json.dumps(SAMPLE_RECORDS)) + result = dc.compare_data(str(a), str(b)) + assert result["equal"] is True + + def test_compare_different_row_order_not_equal(self, dc, tmp_path): + # EG / FAULT-HUNTING: SPEC documents compare_data as ORDER SENSITIVE. + # Two files containing the same records in different order must + # currently report "not equal". This documents contract behavior. + a = tmp_path / "a.json" + b = tmp_path / "b.json" + a.write_text(json.dumps(SAMPLE_RECORDS)) + b.write_text(json.dumps(list(reversed(SAMPLE_RECORDS)))) + result = dc.compare_data(str(a), str(b)) + assert result["equal"] is False + + def test_compare_different_lengths(self, dc, tmp_path): + # EG: unequal record counts -> not equal with descriptive reason. + a = tmp_path / "a.json" + b = tmp_path / "b.json" + a.write_text(json.dumps(SAMPLE_RECORDS)) + b.write_text(json.dumps(SAMPLE_RECORDS[:2])) + result = dc.compare_data(str(a), str(b)) + assert result["equal"] is False + assert "Different number" in result["reason"] diff --git a/backend/tests/blackbox/routers/__init__.py b/backend/tests/blackbox/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/routers/test_auth_router.py b/backend/tests/blackbox/routers/test_auth_router.py new file mode 100644 index 00000000..10a72c95 --- /dev/null +++ b/backend/tests/blackbox/routers/test_auth_router.py @@ -0,0 +1,149 @@ +""" +Black-box tests for routers.auth.router (the only router mounted in app.py). + +Derived from SPEC.md §auth router (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. + +SPEC caveat: the endpoint body is `pass`; business logic is not implemented. +These tests therefore only validate the Pydantic schema + FastAPI wiring: +accepted-payload class -> 201, rejected-payload class -> 422, etc. +""" +import pytest + +pytestmark = pytest.mark.blackbox + + +SIGNUP_URL = "/api/v1.0/auth/Signup" + + +def _payload(**overrides): + base = { + "email": "user@example.com", + "username": "alice", + "password": "correcthorse", # 12 chars, within 8..15 + } + base.update(overrides) + return base + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestSignupEP: + """ + EP partitions for POST /Signup: + payload shape: valid / missing-field / wrong-type + password length: in-range (8..15) / below-min / above-max + """ + + def test_valid_payload_returns_201(self, client): + # EP: valid class -> 201 per SPEC. + r = client.post(SIGNUP_URL, json=_payload()) + assert r.status_code == 201 + + def test_missing_email_returns_422(self, client): + # EP: missing-required-field class -> 422. + payload = _payload() + payload.pop("email") + r = client.post(SIGNUP_URL, json=payload) + assert r.status_code == 422 + + def test_missing_username_returns_422(self, client): + # EP: another missing-required-field representative. + payload = _payload() + payload.pop("username") + r = client.post(SIGNUP_URL, json=payload) + assert r.status_code == 422 + + def test_missing_password_returns_422(self, client): + # EP: missing password -> 422. + payload = _payload() + payload.pop("password") + r = client.post(SIGNUP_URL, json=payload) + assert r.status_code == 422 + + def test_wrong_type_password_returns_422(self, client): + # EP: wrong-type class (password must be str). + r = client.post(SIGNUP_URL, json=_payload(password=12345678)) + # FastAPI/Pydantic v2 may accept int and coerce; still 4xx if length + # rule fails. Accept either 422 or 201 but not 5xx. + assert r.status_code in (201, 422) + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestPasswordLengthBoundariesBA: + """Boundary analysis on password length (min=8, max=15 per schema).""" + + def test_password_length_seven_rejected(self, client): + # BA: length=7 (min-1) -> 422. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 7)) + assert r.status_code == 422 + + def test_password_length_eight_accepted(self, client): + # BA: length=8 (min) -> 201. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 8)) + assert r.status_code == 201 + + def test_password_length_nine_accepted(self, client): + # BA: length=9 (min+1) -> 201. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 9)) + assert r.status_code == 201 + + def test_password_length_fourteen_accepted(self, client): + # BA: length=14 (max-1) -> 201. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 14)) + assert r.status_code == 201 + + def test_password_length_fifteen_accepted(self, client): + # BA: length=15 (max) -> 201. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 15)) + assert r.status_code == 201 + + def test_password_length_sixteen_rejected(self, client): + # BA: length=16 (max+1) -> 422. + r = client.post(SIGNUP_URL, json=_payload(password="a" * 16)) + assert r.status_code == 422 + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_wrong_method_returns_405(self, client): + # EG: GET on a POST-only endpoint -> 405 per HTTP spec. + r = client.get(SIGNUP_URL) + assert r.status_code == 405 + + def test_lowercase_path_returns_404(self, client): + # EG: SPEC notes the path is /Signup (capital S). Lowercase should 404. + r = client.post("/api/v1.0/auth/signup", json=_payload()) + assert r.status_code == 404 + + def test_empty_body_returns_422(self, client): + # EG: empty JSON body -> validation errors for all required fields. + r = client.post(SIGNUP_URL, json={}) + assert r.status_code == 422 + + def test_extra_fields_ignored_and_accepted(self, client): + # EG: unexpected fields. Default Pydantic v2 behavior: ignored. 201. + r = client.post(SIGNUP_URL, json=_payload(extra_field="x")) + assert r.status_code == 201 + + def test_invalid_json_body_returns_422(self, client): + # EG: malformed JSON body -> 422 (FastAPI surfaces as validation error). + r = client.post( + SIGNUP_URL, + data="this is not json", + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 422 + + def test_empty_string_password_returns_422(self, client): + # EG: empty password (length=0) -> below min_length. + r = client.post(SIGNUP_URL, json=_payload(password="")) + assert r.status_code == 422 diff --git a/backend/tests/blackbox/web_scraping/__init__.py b/backend/tests/blackbox/web_scraping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/blackbox/web_scraping/test_weather_checker.py b/backend/tests/blackbox/web_scraping/test_weather_checker.py new file mode 100644 index 00000000..795b2578 --- /dev/null +++ b/backend/tests/blackbox/web_scraping/test_weather_checker.py @@ -0,0 +1,225 @@ +""" +Black-box tests for scripts.web_scraping.weather_checker. + +Derived from SPEC.md §weather_checker (no peeking at implementation). +Applies EP / BA / EG per proposal §2.2. + +This is the **HTTP mock target** (SPEC + PLAN). All tests use +unittest.mock.patch on requests.get - no network call is made. +""" +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from scripts.web_scraping.weather_checker import WeatherChecker + +pytestmark = pytest.mark.blackbox + + +OPENWEATHER_OK_JSON = { + "name": "London", + "sys": {"country": "GB"}, + "main": {"temp": 10.5, "feels_like": 8.2, "humidity": 75, "pressure": 1013}, + "weather": [{"description": "light rain"}], + "wind": {"speed": 3.5}, + "visibility": 8000, +} + +WTTR_OK_JSON = { + "current_condition": [{ + "temp_C": "15", + "FeelsLikeC": "14", + "weatherDesc": [{"value": "Partly cloudy"}], + "humidity": "60", + "pressure": "1012", + "windspeedKmph": "10", + "visibility": "10", + }] +} + + +def _ok_response(payload): + m = MagicMock() + m.status_code = 200 + m.json.return_value = payload + m.raise_for_status = MagicMock() + return m + + +# ========================================================================= +# EP â Equivalence Partitioning +# ========================================================================= + +class TestGetWeatherByCityEP: + """ + EP partitions for get_weather_by_city: + api_key: provided vs absent + response: success vs network failure vs non-network exception + """ + + def test_with_api_key_success(self): + # EP: api_key provided + 200 OK -> parsed dict returned. + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response(OPENWEATHER_OK_JSON)): + result = checker.get_weather_by_city("London") + assert result["city"] == "London" + assert result["country"] == "GB" + assert "10.5" in result["temperature"] + + def test_without_api_key_uses_free_fallback(self): + # EP: api_key absent -> delegates to get_weather_free (wttr.in). + checker = WeatherChecker() + with patch("requests.get", return_value=_ok_response(WTTR_OK_JSON)) as mock_get: + result = checker.get_weather_by_city("London") + # Verify wttr.in was called (not openweathermap). + assert "wttr.in" in mock_get.call_args.args[0] + assert result["description"] == "Partly cloudy" + + def test_request_exception_falls_back_to_free(self): + # EP: network failure class -> fallback path per SPEC. + checker = WeatherChecker(api_key="abc") + + def _side_effect(url, **kw): + if "openweathermap" in url: + raise requests.exceptions.ConnectionError("boom") + return _ok_response(WTTR_OK_JSON) + + with patch("requests.get", side_effect=_side_effect): + result = checker.get_weather_by_city("London") + # Fallback succeeded, so result exists. + assert result is not None + assert result["description"] == "Partly cloudy" + + def test_non_request_exception_returns_none(self): + # EP: non-RequestException class -> returns None, no further fallback. + checker = WeatherChecker(api_key="abc") + bad_resp = MagicMock() + bad_resp.raise_for_status = MagicMock() + bad_resp.json.side_effect = ValueError("not json") + with patch("requests.get", return_value=bad_resp): + result = checker.get_weather_by_city("London") + assert result is None + + +class TestGetWeatherByCoordinatesEP: + def test_no_api_key_returns_none(self): + # EP: coordinates require api_key per SPEC. + checker = WeatherChecker() + with patch("requests.get") as mock_get: + assert checker.get_weather_by_coordinates(51.5, -0.1) is None + # Fast failure - no network call should have been made. + mock_get.assert_not_called() + + def test_with_api_key_success(self): + # EP: api_key + valid response. + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response(OPENWEATHER_OK_JSON)): + result = checker.get_weather_by_coordinates(51.5, -0.1) + assert result["city"] == "London" + + +# ========================================================================= +# BA â Boundary Analysis +# ========================================================================= + +class TestBoundaries: + def test_forecast_days_parameter_controls_cnt(self): + # BA: lower-meaningful value for days (1 day -> cnt=8). + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response({ + "city": {"name": "X"}, + "list": [{ + "dt": 1_700_000_000, + "main": {"temp": 5.0, "humidity": 50}, + "weather": [{"description": "ok"}], + }] + })) as mock_get: + checker.get_weather_forecast("X", days=1) + assert mock_get.call_args.kwargs["params"]["cnt"] == 8 + + def test_zero_days_produces_zero_cnt(self): + # BA: edge value days=0 -> cnt=0 (documents current behavior; not + # validated as an input constraint anywhere in SPEC). + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response({ + "city": {"name": "X"}, + "list": [], + })) as mock_get: + checker.get_weather_forecast("X", days=0) + assert mock_get.call_args.kwargs["params"]["cnt"] == 0 + + def test_request_uses_10_second_timeout(self): + # BA: SPEC documents a 10s timeout. Verify it's passed through. + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response(OPENWEATHER_OK_JSON)) as mock_get: + checker.get_weather_by_city("X") + assert mock_get.call_args.kwargs["timeout"] == 10 + + +# ========================================================================= +# EG â Error Guessing +# ========================================================================= + +class TestErrorGuessing: + def test_metric_units_temp_unit_celsius(self): + # EG: metric -> °C formatted temp. + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response(OPENWEATHER_OK_JSON)): + result = checker.get_weather_by_city("London", units="metric") + assert "\u00b0C" in result["temperature"] + + def test_imperial_units_temp_unit_fahrenheit(self): + # EG: imperial -> °F formatted temp. + checker = WeatherChecker(api_key="abc") + with patch("requests.get", return_value=_ok_response(OPENWEATHER_OK_JSON)): + result = checker.get_weather_by_city("London", units="imperial") + assert "\u00b0F" in result["temperature"] + + def test_timeout_during_api_call_falls_back_to_free(self): + # EG: timeouts are a common RequestException subclass. + checker = WeatherChecker(api_key="abc") + + def _side_effect(url, **kw): + if "openweathermap" in url: + raise requests.exceptions.Timeout("slow") + return _ok_response(WTTR_OK_JSON) + + with patch("requests.get", side_effect=_side_effect): + result = checker.get_weather_by_city("London") + assert result is not None + + def test_missing_wind_key_crashes_format(self): + # EG / FAULT-HUNTING: SPEC §Gaps #6 warns that format_weather_data + # crashes on ANY missing required key (except visibility which uses + # .get). A well-formed HTTP 200 with no 'wind' is a plausible upstream + # contract change. Designed to FAIL: intended contract per SPEC is + # consistent error handling, not KeyError bubbling up. + checker = WeatherChecker(api_key="abc") + payload = dict(OPENWEATHER_OK_JSON) + payload.pop("wind") + with patch("requests.get", return_value=_ok_response(payload)): + result = checker.get_weather_by_city("London") + # Contract: should return None (caught) rather than crash. + # Current implementation: the KeyError is caught by the top-level + # `except Exception` path, returning None. Assert the graceful outcome. + assert result is None + + def test_save_weather_data_appends_to_existing_log(self, tmp_path, monkeypatch): + # EG: log file appends, not overwrites. + monkeypatch.chdir(tmp_path) + checker = WeatherChecker() + first = {"city": "A", "temperature": "10°C"} + second = {"city": "B", "temperature": "20°C"} + checker.save_weather_data(first, filename="log.json") + checker.save_weather_data(second, filename="log.json") + import json + data = json.loads((tmp_path / "log.json").read_text()) + assert len(data) == 2 + + def test_display_weather_does_not_crash_on_none(self, capsys): + # EG: display_weather(None) per SPEC prints "No weather data available". + checker = WeatherChecker() + checker.display_weather(None) + out = capsys.readouterr().out + assert "No weather data" in out