Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
40 changes: 40 additions & 0 deletions backend/tests/SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 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._

## 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._
Empty file added backend/tests/__init__.py
Empty file.
Empty file added backend/tests/api/__init__.py
Empty file.
Empty file.
Empty file.
194 changes: 194 additions & 0 deletions backend/tests/blackbox/automation/test_auto_email_sender.py
Original file line number Diff line number Diff line change
@@ -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
156 changes: 156 additions & 0 deletions backend/tests/blackbox/automation/test_file_organizer.py
Original file line number Diff line number Diff line change
@@ -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 `<ext>/`.
_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 `<ext>/<filename>` 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
Empty file.
Loading
Loading