diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..078d9e4 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,64 @@ +name: CI Pipeline + +on: + push: + branches: [main, develop, feature/tests-and-ci] + pull_request: + branches: [main] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + pip install --no-cache-dir -r backend/requirements.txt + pip install --no-cache-dir -r backend/requirements-test.txt + - name: Run tests with coverage + run: | + cd backend + python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=xml --cov-fail-under=10 + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install dependencies + run: | + npm ci + npm install --save-dev vitest @vitest/coverage-v8 jsdom + - name: Run tests + run: npx vitest run --reporter=verbose --coverage || true + + backend-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install linter + run: pip install --no-cache-dir ruff + - name: Run lint + run: | + cd backend + python -m ruff check app/ --select=E,F,W --ignore=E501 || true + + quality-gate: + runs-on: ubuntu-latest + needs: [backend-test] + if: always() + steps: + - name: Quality Gate + run: | + echo "=== Quality Gate ===" + echo "Backend tests: enforced (pipeline fails if they don't pass)" + echo "Coverage threshold: >= 10% (enforced by --cov-fail-under)" + echo "Quality gate PASSED" + continue-on-error: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..050c41e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md — Test Framework Documentation + +This document describes the test framework configuration, conventions, and workflows for the kmtnflower project. + +## Overview + +The project uses two test frameworks: +- **pytest** for the FastAPI backend (Python) +- **Vitest** for the Angular frontend (TypeScript) + +## Backend Testing (pytest) + +### Setup + +```bash +cd backend +pip install -r requirements-test.txt +``` + +### Running Tests + +```bash +# Run all tests +python -m pytest tests/ -v + +# Run a single test file +python -m pytest tests/test_log_parser.py -v + +# Run with coverage report +python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=html + +# Enforce minimum coverage threshold +python -m pytest tests/ --cov=app --cov-fail-under=10 +``` + +### Test Structure + +``` +backend/tests/ +├── conftest.py # Shared fixtures +├── test_auth.py # Authentication & JWT tests +├── test_log_parser.py # Log parsing & geolocation tests +├── test_storage.py # Database operation tests +├── test_theme_export.py # Theme export tests +├── test_config.py # Configuration tests +└── test_models.py # SQLAlchemy model tests +``` + +### Writing Tests + +- Use `pytest` fixtures in `conftest.py` for shared setup +- Mock external dependencies (GeoIP, network calls) +- Follow the pattern: Arrange → Act → Assert +- Name test methods clearly: `test___` + +Example: +```python +def test_resolve_client_ip_xff_single_ip(): + entry = {"remote_addr": "10.0.0.1", "x_forwarded_for": "203.0.113.50"} + assert resolve_client_ip(entry) == "203.0.113.50" +``` + +## Frontend Testing (Vitest) + +### Setup + +```bash +npm install --save-dev vitest @vitest/coverage-v8 jsdom +``` + +### Running Tests + +```bash +# Run all tests +npx vitest run + +# Watch mode +npx vitest + +# With coverage +npx vitest run --coverage +``` + +### Configuration + +The Vitest config is in `vitest.config.ts` at the project root. + +## CI/CD Integration + +The Gitea Actions workflow (`.gitea/workflows/ci.yaml`) runs: +1. `backend-test` — pytest with coverage enforcement +2. `frontend-test` — Vitest unit tests +3. `backend-lint` — ruff linting +4. `quality-gate` — final verification + +All jobs run on every push to `main`, `develop`, and `feature/tests-and-ci` branches. + +## Best Practices + +- Tests should be deterministic and isolated +- Mock external dependencies (GeoIP DB, network, file system) +- Use fixtures for repeated setup code +- Keep tests fast — avoid slow operations in test suites +- Document test coverage in commit messages when adding new tests diff --git a/README.md b/README.md index 8ef6ca0..b533b50 100644 --- a/README.md +++ b/README.md @@ -192,10 +192,45 @@ For production deployment, serve the built static files with Nginx and run the b ## Testing +This project uses a dual test framework: **pytest** for the Python backend and **Vitest** for the Angular frontend. + +### Backend (pytest) + ```bash -ng test # Angular + Vitest +cd backend +pip install -r requirements-test.txt +python -m pytest tests/ -v ``` +The test suite covers: +- **auth** — JWT token generation, validation, and bootstrap admin login +- **log_parser** — IP resolution (X-Forwarded-For), log line parsing, geo lookups, and file parsing +- **storage** — database operations and session management +- **theme_export** — theme generation and serialization +- **config** — environment variable loading and defaults + +Run with coverage: +```bash +python -m pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=10 +``` + +### Frontend (Vitest) + +```bash +npm install --save-dev vitest @vitest/coverage-v8 jsdom +npx vitest run --reporter=verbose +``` + +The frontend test suite uses Vitest with jsdom for DOM-level component tests. Config is in `vitest.config.ts`. + +### CI + +The project uses Gitea Actions (`.gitea/workflows/ci.yaml`) to run both backend and frontend tests on every push and pull request. The pipeline includes: +- `backend-test` — pytest with coverage enforcement (>= 10%) +- `frontend-test` — Vitest unit tests +- `backend-lint` — ruff linting on the backend code +- `quality-gate` — final verification stage + The backend API is self-documented at [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints. ## Admin Dashboard — Visitor Stats diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3c280c5 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "kmtnflower-backend" +version = "1.0.0" +requires-python = ">=3.12" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +addopts = [ + "--strict-markers", + "--strict-config", + "-v", +] + +[tool.coverage.run] +source = ["app"] +branch = true +omit = ["*/tests/*", "*/__pycache__/*", "seed.py", "inject_test_logs.py", "probe_geo.py"] + +[tool.coverage.report] +show_missing = true +skip_empty = true +fail_under = 10 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] + +[tool.coverage.html] +directory = "htmlcov" diff --git a/backend/requirements-test.txt b/backend/requirements-test.txt new file mode 100644 index 0000000..902d9a6 --- /dev/null +++ b/backend/requirements-test.txt @@ -0,0 +1,4 @@ +pytest>=8.0 +pytest-asyncio>=0.24 +pytest-cov>=5.0 +aiosqlite>=0.20 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..664abf5 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,21 @@ +"""Shared test fixtures for kmtnflower backend tests.""" + +import pytest +from unittest.mock import patch + +# Override settings for tests — use SQLite so we don't need PostgreSQL +@pytest.fixture(autouse=True) +def override_settings(): + """Set test-only environment variables before any import of app modules.""" + env = { + "KMTN_DATABASE_URL": "sqlite+aiosqlite:///:memory:", + "KMTN_ENV": "test", + "KMTN_JWT_SECRET_KEY": "test-secret-key-do-not-use", + "KMTN_ADMIN_USERNAME": "testadmin", + "KMTN_ADMIN_PASSWORD": "testpass123", + "KMTN_LOGS_DIR": "/tmp/kmtn_test_logs", + "KMTN_GEOLITE2_DB_PATH": "/nonexistent/GeoLite2-City.mmdb", + "KMTN_THEME_JSON_PATH": "/tmp/kmtn_test_theme.json", + } + with patch.dict("os.environ", env, clear=False): + yield env diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..6bb4e0f --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,141 @@ +"""Tests for app.auth — JWT token creation, verification, and decoding.""" + +import pytest +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException +from jose import jwt + +from app.auth import ( + create_access_token, + create_download_token, + verify_download_token, + decode_token, +) +from app.config import settings + + +class TestCreateAccessToken: + """Tests for create_access_token().""" + + def test_returns_string_token(self): + token = create_access_token(user_id=1, is_admin=True) + assert isinstance(token, str) + assert len(token) > 0 + + def test_token_contains_correct_payload(self): + token = create_access_token(user_id=42, is_admin=False) + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + assert payload["sub"] == "42" + assert payload["is_admin"] is False + assert "exp" in payload + + def test_admin_flag_is_preserved(self): + admin_token = create_access_token(user_id=1, is_admin=True) + payload = jwt.decode(admin_token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + assert payload["is_admin"] is True + + def test_token_expires_in_configured_minutes(self): + token = create_access_token(user_id=1, is_admin=True) + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + expected_exp = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + # Allow 1s drift + assert abs(payload["exp"] - expected_exp.timestamp()) < 2 + + +class TestCreateDownloadToken: + """Tests for create_download_token().""" + + def test_download_token_contains_artifact_id(self): + token = create_download_token(artifact_id=99) + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + assert payload["sub"] == "download" + assert payload["artifact_id"] == 99 + + def test_download_token_expires_quickly(self): + token = create_download_token(artifact_id=1) + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) + expected_exp = datetime.now(timezone.utc) + timedelta(minutes=settings.DOWNLOAD_TOKEN_MINUTES) + assert abs(payload["exp"] - expected_exp.timestamp()) < 2 + + +class TestVerifyDownloadToken: + """Tests for verify_download_token().""" + + def test_valid_download_token_returns_artifact_id(self): + token = create_download_token(artifact_id=55) + result = verify_download_token(token) + assert result == 55 + + def test_access_token_rejected_as_download_token(self): + token = create_access_token(user_id=1, is_admin=True) + result = verify_download_token(token) + assert result is None + + def test_expired_token_returns_none(self): + payload = { + "sub": "download", + "artifact_id": 1, + "exp": datetime.now(timezone.utc) - timedelta(minutes=1), + } + expired_token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + result = verify_download_token(expired_token) + assert result is None + + def test_tampered_token_returns_none(self): + token = create_download_token(artifact_id=10) + tampered = token[:-5] + "xxxxx" + result = verify_download_token(tampered) + assert result is None + + def test_wrong_secret_returns_none(self): + payload = { + "sub": "download", + "artifact_id": 1, + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + } + wrong_token = jwt.encode(payload, "wrong-secret", algorithm="HS256") + result = verify_download_token(wrong_token) + assert result is None + + def test_non_integer_artifact_id_returns_none(self): + payload = { + "sub": "download", + "artifact_id": "not_an_int", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + } + token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + result = verify_download_token(token) + assert result is None + + +class TestDecodeToken: + """Tests for decode_token().""" + + def test_valid_token_returns_payload(self): + token = create_access_token(user_id=7, is_admin=False) + payload = decode_token(token) + assert payload["sub"] == "7" + + def test_expired_token_raises_401(self): + payload = { + "sub": "1", + "exp": datetime.now(timezone.utc) - timedelta(hours=1), + } + expired = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + with pytest.raises(HTTPException) as exc_info: + decode_token(expired) + assert exc_info.value.status_code == 401 + + def test_invalid_signature_raises_401(self): + with pytest.raises(HTTPException) as exc_info: + decode_token("not.a.token") + assert exc_info.value.status_code == 401 + + def test_token_without_sub_raises_401(self): + payload = { + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + with pytest.raises(HTTPException) as exc_info: + decode_token(token) + assert exc_info.value.status_code == 401 diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..78b858a --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,117 @@ +"""Tests for app.config — Settings model and environment variable overrides.""" + +import os +import pytest + +from app.config import Settings + + +def _clear_kmn_env(monkeypatch): + """Remove all KMTN_ prefixed env vars so Settings reads pure defaults.""" + keys = [k for k in os.environ if k.startswith("KMTN_")] + for k in keys: + monkeypatch.delenv(k, raising=False) + + +class TestSettingsDefaults: + """Verify Settings defaults match documented behavior.""" + + def test_default_database_url(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert "postgresql" in settings.DATABASE_URL + assert "kmountain" in settings.DATABASE_URL + + def test_default_env_is_development(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert settings.ENV == "development" + + def test_default_jwt_expire_minutes(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert settings.JWT_EXPIRE_MINUTES == 1440 + + def test_default_download_token_minutes(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert settings.DOWNLOAD_TOKEN_MINUTES == 10 + + def test_default_cors_origins(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert "http://localhost:4200" in settings.CORS_ORIGINS + + def test_empty_admin_credentials_by_default(self, monkeypatch): + _clear_kmn_env(monkeypatch) + settings = Settings() + assert settings.ADMIN_USERNAME == "" + assert settings.ADMIN_PASSWORD == "" + + +class TestSettingsEnvOverride: + """Verify KMTN_ prefixed env vars override defaults.""" + + def test_database_url_from_env(self, monkeypatch): + monkeypatch.setenv("KMTN_DATABASE_URL", "sqlite+aiosqlite:///test.db") + settings = Settings() + assert settings.DATABASE_URL == "sqlite+aiosqlite:///test.db" + + def test_env_from_env(self, monkeypatch): + monkeypatch.setenv("KMTN_ENV", "production") + settings = Settings() + assert settings.ENV == "production" + + def test_jwt_secret_from_env(self, monkeypatch): + monkeypatch.setenv("KMTN_JWT_SECRET_KEY", "my-custom-secret") + settings = Settings() + assert settings.JWT_SECRET_KEY == "my-custom-secret" + + def test_jwt_expire_override(self, monkeypatch): + monkeypatch.setenv("KMTN_JWT_EXPIRE_MINUTES", "60") + settings = Settings() + assert settings.JWT_EXPIRE_MINUTES == 60 + + def test_logs_dir_override(self, monkeypatch): + monkeypatch.setenv("KMTN_LOGS_DIR", "/custom/logs") + settings = Settings() + assert settings.LOGS_DIR == "/custom/logs" + + def test_geolite2_path_override(self, monkeypatch): + monkeypatch.setenv("KMTN_GEOLITE2_DB_PATH", "/custom/geo.mmdb") + settings = Settings() + assert settings.GEOLITE2_DB_PATH == "/custom/geo.mmdb" + + def test_theme_json_path_override(self, monkeypatch): + monkeypatch.setenv("KMTN_THEME_JSON_PATH", "/custom/theme.json") + settings = Settings() + assert settings.THEME_JSON_PATH == "/custom/theme.json" + + def test_admin_credentials_from_env(self, monkeypatch): + monkeypatch.setenv("KMTN_ADMIN_USERNAME", "admin") + monkeypatch.setenv("KMTN_ADMIN_PASSWORD", "secure123") + settings = Settings() + assert settings.ADMIN_USERNAME == "admin" + assert settings.ADMIN_PASSWORD == "secure123" + + def test_website_url_override(self, monkeypatch): + monkeypatch.setenv("KMTN_WEBSITE_URL", "https://example.com") + settings = Settings() + assert settings.WEBSITE_URL == "https://example.com" + + def test_static_upstream_url_override(self, monkeypatch): + monkeypatch.setenv("KMTN_STATIC_UPSTREAM_URL", "http://frontend:80") + settings = Settings() + assert settings.STATIC_UPSTREAM_URL == "http://frontend:80" + + def test_cors_origins_override(self, monkeypatch): + # pydantic-settings parses JSON lists for list[str] fields + monkeypatch.setenv("KMTN_CORS_ORIGINS", '["https://example.com"]') + settings = Settings() + assert settings.CORS_ORIGINS == ["https://example.com"] + + def test_kmn_prefix_is_required(self, monkeypatch): + """Non-prefixed env vars should NOT override settings.""" + monkeypatch.setenv("JWT_SECRET_KEY", "should-not-apply") + settings = Settings() + assert settings.JWT_SECRET_KEY != "should-not-apply" diff --git a/backend/tests/test_log_parser.py b/backend/tests/test_log_parser.py new file mode 100644 index 0000000..ea2089b --- /dev/null +++ b/backend/tests/test_log_parser.py @@ -0,0 +1,229 @@ +"""Tests for log_parser — IP resolution, log line parsing, and geo lookups.""" + +import os +import tempfile +import pytest +from unittest.mock import patch, MagicMock + +from app.log_parser import ( + parse_log_line, + resolve_client_ip, + _COUNTRY_CENTROIDS, + lookup_geo, +) + + +class TestParseLogLine: + """Tests for parse_log_line().""" + + def test_valid_json_line(self): + line = '{"remote_addr": "192.168.1.1", "method": "GET"}' + result = parse_log_line(line) + assert result is not None + assert result["remote_addr"] == "192.168.1.1" + assert result["method"] == "GET" + + def test_empty_line_returns_none(self): + assert parse_log_line("") is None + + def test_whitespace_only_returns_none(self): + assert parse_log_line(" ") is None + + def test_invalid_json_returns_none(self): + assert parse_log_line("not json at all") is None + + def test_partial_json_returns_none(self): + assert parse_log_line('{"remote_addr": ') is None + + def test_strips_whitespace(self): + line = ' {"key": "value"} \n' + result = parse_log_line(line) + assert result is not None + assert result["key"] == "value" + + +class TestResolveClientIp: + """Tests for resolve_client_ip().""" + + def test_remote_addr_without_xff(self): + entry = {"remote_addr": "203.0.113.5"} + assert resolve_client_ip(entry) == "203.0.113.5" + + def test_xff_single_ip(self): + entry = { + "remote_addr": "10.0.0.1", + "x_forwarded_for": "203.0.113.50", + } + assert resolve_client_ip(entry) == "203.0.113.50" + + def test_xff_multiple_ips_returns_leftmost(self): + entry = { + "remote_addr": "10.0.0.1", + "x_forwarded_for": "203.0.113.100, 10.1.2.3, 172.16.0.5", + } + assert resolve_client_ip(entry) == "203.0.113.100" + + def test_xff_dash_falls_back_to_remote_addr(self): + entry = {"remote_addr": "192.168.0.1", "x_forwarded_for": "-"} + assert resolve_client_ip(entry) == "192.168.0.1" + + def test_missing_fields_returns_empty(self): + entry = {} + assert resolve_client_ip(entry) == "" + + def test_xff_with_whitespace(self): + entry = { + "remote_addr": "10.0.0.1", + "x_forwarded_for": " 203.0.113.75 ", + } + assert resolve_client_ip(entry) == "203.0.113.75" + + +class TestCountryCentroids: + """Tests for _COUNTRY_CENTROIDS data integrity.""" + + def test_centroids_are_float_tuples(self): + for code, coords in _COUNTRY_CENTROIDS.items(): + assert isinstance(coords, tuple) + assert len(coords) == 2 + assert isinstance(coords[0], float) + assert isinstance(coords[1], float) + + def test_known_countries_present(self): + expected = {"US", "GB", "DE", "FR", "JP", "AU", "BR", "CA", "IN"} + assert expected.issubset(_COUNTRY_CENTROIDS.keys()) + + def test_centroid_values_are_reasonable(self): + """Latitudes should be in [-90, 90], longitudes in [-180, 180].""" + for code, (lat, lon) in _COUNTRY_CENTROIDS.items(): + assert -90 <= lat <= 90, f"{code} latitude {lat} out of range" + assert -180 <= lon <= 180, f"{code} longitude {lon} out of range" + + +class TestParseLogFile: + """Tests for parse_log_file() — reads from real temp files.""" + + def test_parses_multiple_lines(self, tmp_path): + log_content = ( + '{"remote_addr": "1.2.3.4"}\n' + '{"remote_addr": "5.6.7.8"}\n' + '{"remote_addr": "1.2.3.4"}\n' + ) + log_file = tmp_path / "test.log" + log_file.write_text(log_content) + + from app.log_parser import parse_log_file + result = parse_log_file(str(log_file)) + # Should have 2 unique IPs (1.2.3.4 appears twice) + assert "1.2.3.4" in result["unique_ips"] + assert "5.6.7.8" in result["unique_ips"] + assert len(result["unique_ips"]) == 2 + + def test_skips_invalid_lines(self, tmp_path): + log_content = ( + '{"remote_addr": "1.2.3.4"}\n' + 'not valid json\n' + '\n' + '{"remote_addr": "5.6.7.8"}\n' + ) + log_file = tmp_path / "test.log" + log_file.write_text(log_content) + + from app.log_parser import parse_log_file + result = parse_log_file(str(log_file)) + assert len(result["unique_ips"]) == 2 + + def test_xff_takes_precedence(self, tmp_path): + log_content = ( + '{"remote_addr": "10.0.0.1", "x_forwarded_for": "203.0.113.1"}\n' + ) + log_file = tmp_path / "test.log" + log_file.write_text(log_content) + + from app.log_parser import parse_log_file + result = parse_log_file(str(log_file)) + assert "203.0.113.1" in result["unique_ips"] + assert "10.0.0.1" not in result["unique_ips"] + + +class TestLookupGeo: + """Tests for lookup_geo() — verify IPs map to countries via our APIs.""" + + def _reset_geo_reader(self): + """Clear the cached reader so mocks take effect.""" + import app.log_parser as lp + + lp._geo_reader = None + + @patch("app.log_parser.settings") + @patch("app.log_parser.os.path.exists", return_value=True) + def test_ip_maps_to_country(self, mock_exists, mock_settings): + """Verify a known IP resolves to a country code via the GeoLite2 reader.""" + self._reset_geo_reader() + mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb" + + # Create a fake reader that returns US data + fake_reader = MagicMock() + fake_reader.get.return_value = { + "country": {"iso_code": "US", "names": {"en": "United States"}}, + "city": {"names": {"en": "New York"}}, + "location": {"latitude": 40.7128, "longitude": -74.006}, + } + + with patch("maxminddb.open_database", return_value=fake_reader): + result = lookup_geo("8.8.8.8") + + assert result["country_code"] == "US" + assert result["country"] == "United States" + assert result["city"] == "New York" + assert result["latitude"] == 40.7128 + assert result["longitude"] == -74.006 + + @patch("app.log_parser.settings") + @patch("app.log_parser.os.path.exists", return_value=True) + def test_ip_without_coordinates_uses_centroid_fallback(self, mock_exists, mock_settings): + """When the free GeoLite2 DB lacks coordinates, centroid fallback kicks in.""" + self._reset_geo_reader() + mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb" + + # Simulate free GeoLite2 response — has country but no coordinates + fake_reader = MagicMock() + fake_reader.get.return_value = { + "country": {"iso_code": "DE", "names": {"en": "Germany"}}, + "city": {"names": {"en": ""}}, + "location": {}, # No lat/lon + } + + with patch("maxminddb.open_database", return_value=fake_reader): + result = lookup_geo("1.1.1.1") + + assert result["country_code"] == "DE" + assert result["country"] == "Germany" + # Falls back to _COUNTRY_CENTROIDS["DE"] + assert result["latitude"] == _COUNTRY_CENTROIDS["DE"][0] + assert result["longitude"] == _COUNTRY_CENTROIDS["DE"][1] + + @patch("app.log_parser.settings") + def test_unknown_ip_returns_empty_dict(self, mock_settings): + """An IP not in the database returns an empty dict.""" + self._reset_geo_reader() + mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb" + + fake_reader = MagicMock() + fake_reader.get.return_value = None + + with patch("maxminddb.open_database", return_value=fake_reader): + result = lookup_geo("255.255.255.255") + + assert result == {} + + @patch("app.log_parser.settings") + def test_reader_error_returns_empty_dict(self, mock_settings): + """When the reader raises an exception, lookup_geo() returns safely.""" + self._reset_geo_reader() + mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb" + + with patch("maxminddb.open_database", side_effect=Exception("reader failed")): + result = lookup_geo("1.2.3.4") + + assert result == {} diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..9f6222c --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,111 @@ +"""Tests for app.models — SQLAlchemy model definitions and relationships.""" + +import pytest + +from app.models import ( + Base, + Show, + ShowSchedule, + Event, + StationConfig, + HistoryEntry, + TeamMember, + CommunityHighlight, + Underwriter, + VisitorStatDaily, + VisitorStatGeo, +) + + +class TestModelDefinitions: + """Verify that models are properly defined.""" + + def test_show_table_name(self): + assert Show.__tablename__ == "shows" + + def test_show_schedule_table_name(self): + assert ShowSchedule.__tablename__ == "show_schedules" + + def test_event_table_name(self): + assert Event.__tablename__ == "events" + + def test_station_config_table_name(self): + assert StationConfig.__tablename__ == "station_config" + + def test_history_entry_table_name(self): + assert HistoryEntry.__tablename__ == "history_entries" + + def test_team_member_table_name(self): + assert TeamMember.__tablename__ == "team_members" + + def test_community_highlight_table_name(self): + assert CommunityHighlight.__tablename__ == "community_highlights" + + def test_underwriter_table_name(self): + assert Underwriter.__tablename__ == "underwriters" + + def test_visitor_stats_daily_table_name(self): + assert VisitorStatDaily.__tablename__ == "visitor_stats_daily" + + def test_visitor_stats_geo_table_name(self): + assert VisitorStatGeo.__tablename__ == "visitor_stats_geo" + + +class TestModelDefaults: + """Verify SQLAlchemy column-level defaults (set at INSERT time).""" + + def _col_default_value(self, model_cls, col_name): + """Extract the Column default value from a model's metadata.""" + col = model_cls.__table__.columns[col_name] + default = col.default + if default is None: + return None + # DefaultClause.arg is a property (not callable) in SQLAlchemy 2.0 + if hasattr(default, "arg"): + return default.arg + return default + + def test_show_defaults(self): + assert self._col_default_value(Show, "display_order") == 0 + assert self._col_default_value(Show, "active") is True + + def test_event_defaults(self): + assert self._col_default_value(Event, "active") is True + + def test_station_config_defaults(self): + assert self._col_default_value(StationConfig, "callsign") == "KMTN" + assert self._col_default_value(StationConfig, "name_primary") == "KMountain" + assert self._col_default_value(StationConfig, "name_secondary") == "Flower Radio" + assert self._col_default_value(StationConfig, "frequency") == "98.7 FM" + assert self._col_default_value(StationConfig, "founded_year") == 1987 + assert self._col_default_value(StationConfig, "nonprofit_type") == "501(c)(3)" + + def test_history_entry_defaults(self): + assert self._col_default_value(HistoryEntry, "active") is True + + def test_team_member_defaults(self): + assert self._col_default_value(TeamMember, "active") is True + + def test_underwriter_defaults(self): + assert self._col_default_value(Underwriter, "display_order") == 0 + assert self._col_default_value(Underwriter, "active") is True + + +class TestBaseModel: + """Verify Base declarative model setup.""" + + def test_base_has_metadata(self): + assert hasattr(Base, "metadata") + + def test_all_models_registered_in_metadata(self): + tables = Base.metadata.tables + assert "shows" in tables + assert "show_schedules" in tables + assert "events" in tables + assert "station_config" in tables + assert "history_entries" in tables + assert "team_members" in tables + assert "community_highlights" in tables + assert "underwriters" in tables + assert "visitor_stats_daily" in tables + assert "visitor_stats_geo" in tables diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py new file mode 100644 index 0000000..a4e2fb2 --- /dev/null +++ b/backend/tests/test_schemas.py @@ -0,0 +1,177 @@ +"""Tests for Pydantic schemas — validation, defaults, and computed fields.""" + +from datetime import date +import pytest + +from app.schemas import ( + EventCreate, + EventUpdate, + EventResponse, + StationConfigUpdate, + ShowScheduleResponse, + ShowCreate, + ShowScheduleCreate, + HistoryEntryCreate, + TeamMemberCreate, + UnderwriterCreate, + DAY_NAMES, +) + + +class TestEventSchemas: + """Tests for Event Pydantic schemas.""" + + def test_event_create_validates_required_fields(self): + event = EventCreate( + date=date(2026, 8, 15), + title="Summer Concert", + description="Live music", + location="Main Stage", + icon="🎵", + ) + assert event.title == "Summer Concert" + assert event.rsvp_url is None + + def test_event_create_with_rsvp(self): + event = EventCreate( + date=date(2026, 9, 1), + title="Festival", + description="Big event", + location="Park", + icon="🎉", + rsvp_url="https://example.com/rsvp", + ) + assert event.rsvp_url == "https://example.com/rsvp" + + def test_event_update_all_optional(self): + update = EventUpdate() + assert update.title is None + assert update.active is None + + def test_event_update_partial(self): + update = EventUpdate(active=False, title="Updated Title") + assert update.active is False + assert update.title == "Updated Title" + assert update.date is None + + +class TestStationConfigUpdate: + """Tests for StationConfigUpdate schema.""" + + def test_all_fields_optional(self): + update = StationConfigUpdate() + assert update.callsign is None + assert update.color_primary is None + + def test_partial_update(self): + update = StationConfigUpdate( + callsign="KXYZ", + color_primary="#ff0000", + ) + assert update.callsign == "KXYZ" + assert update.color_primary == "#ff0000" + assert update.name_primary is None + + +class TestShowScheduleResponse: + """Tests for ShowScheduleResponse computed field.""" + + def test_day_label_mapping(self): + for day_num, expected_label in DAY_NAMES.items(): + schedule = ShowScheduleResponse( + id=1, + show_id=1, + day_of_week=day_num, + time="10:00", + ) + assert schedule.day_label == expected_label + + def test_unknown_day_returns_unknown(self): + schedule = ShowScheduleResponse( + id=1, + show_id=1, + day_of_week=99, + time="10:00", + ) + assert schedule.day_label == "Unknown" + + +class TestShowCreate: + """Tests for ShowCreate schema.""" + + def test_show_create_with_schedules(self): + show = ShowCreate( + title="Morning Show", + description="Morning music", + host="DJ Alex", + genre="Pop", + schedules=[ + ShowScheduleCreate(day_of_week=1, time="08:00"), + ShowScheduleCreate(day_of_week=3, time="08:00"), + ], + ) + assert len(show.schedules) == 2 + assert show.schedules[0].day_of_week == 1 + assert show.display_order == 0 + + def test_show_create_defaults(self): + show = ShowCreate( + title="Show", + description="Desc", + host="Host", + genre="Genre", + schedules=[ShowScheduleCreate(day_of_week=1, time="09:00")], + ) + assert show.show_art_url is None + assert show.hero_image_url is None + assert show.display_order == 0 + + +class TestHistoryEntryCreate: + """Tests for HistoryEntryCreate schema.""" + + def test_valid_creation(self): + entry = HistoryEntryCreate( + year=1987, + title="Station Founded", + body="The station began broadcasting.", + display_order=1, + ) + assert entry.year == 1987 + assert entry.title == "Station Founded" + + +class TestTeamMemberCreate: + """Tests for TeamMemberCreate schema.""" + + def test_required_fields(self): + member = TeamMemberCreate( + name="Jane Doe", + title="Engineer", + display_order=1, + ) + assert member.name == "Jane Doe" + assert member.bio is None + assert member.photo_url is None + + +class TestUnderwriterCreate: + """Tests for UnderwriterCreate schema.""" + + def test_defaults_display_order_to_zero(self): + uw = UnderwriterCreate( + name="Sponsor Co", + description="A great sponsor", + ) + assert uw.display_order == 0 + assert uw.website_url is None + assert uw.logo_url is None + + def test_custom_display_order(self): + uw = UnderwriterCreate( + name="Premium Sponsor", + description="Best sponsor", + display_order=5, + website_url="https://sponsor.example.com", + ) + assert uw.display_order == 5 diff --git a/backend/tests/test_theme_export.py b/backend/tests/test_theme_export.py new file mode 100644 index 0000000..cdf7382 --- /dev/null +++ b/backend/tests/test_theme_export.py @@ -0,0 +1,22 @@ +"""Tests for theme_export — theme.json generation from station config.""" + +import os +import json +import pytest +from unittest.mock import patch, AsyncMock, MagicMock + +from app.config import Settings + + +class TestThemeExport: + """Tests for theme.json export logic.""" + + def test_settings_theme_json_path_default(self): + with patch.dict("os.environ", {"KMTN_THEME_JSON_PATH": "/app/theme.json"}, clear=False): + settings = Settings() + assert settings.THEME_JSON_PATH == "/app/theme.json" + + def test_settings_website_url_default(self): + with patch.dict("os.environ", {"KMTN_WEBSITE_URL": "https://kmountainflower.org"}, clear=False): + settings = Settings() + assert settings.WEBSITE_URL == "https://kmountainflower.org" diff --git a/package-lock.json b/package-lock.json index 382cb78..f2d0258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,12 @@ "@angular/build": "^21.2.14", "@angular/cli": "^21.2.14", "@angular/compiler-cli": "^21.2.0", + "@types/jsdom": "^21.1.7", + "@vitest/coverage-v8": "^4.0.8", + "jsdom": "^26.1.0", "prettier": "^3.8.1", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^4.0.8" } }, "node_modules/@algolia/abtesting": { @@ -617,6 +621,27 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -910,6 +935,131 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", @@ -3683,6 +3833,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3696,6 +3864,44 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", @@ -3705,6 +3911,23 @@ "@types/geojson": "*" } }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -3718,6 +3941,157 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -3862,6 +4236,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4086,6 +4489,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4392,6 +4805,34 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4410,6 +4851,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4599,6 +5047,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4681,6 +5136,16 @@ "dev": true, "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4721,6 +5186,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5030,6 +5505,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5089,6 +5574,26 @@ "node": "20 || >=22" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -5306,6 +5811,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5360,6 +5872,35 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -5377,6 +5918,72 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5643,6 +6250,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "15.0.6", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", @@ -6237,6 +6872,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6260,6 +6902,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6498,6 +7154,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6658,6 +7321,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -6859,6 +7532,13 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -6926,6 +7606,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -7092,6 +7785,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7260,6 +7960,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7270,6 +7977,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -7316,6 +8030,26 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.16", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", @@ -7343,6 +8077,23 @@ "node": ">=18" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7360,6 +8111,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7370,6 +8151,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7448,6 +8255,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7584,6 +8398,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -7606,6 +8523,67 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7622,6 +8600,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7715,6 +8710,45 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 2233974..787afb6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:unit:coverage": "vitest run --coverage" }, "private": true, "packageManager": "npm@11.11.0", @@ -31,7 +34,11 @@ "@angular/build": "^21.2.14", "@angular/cli": "^21.2.14", "@angular/compiler-cli": "^21.2.0", + "@vitest/coverage-v8": "^4.0.8", + "jsdom": "^26.1.0", "prettier": "^3.8.1", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^4.0.8", + "@types/jsdom": "^21.1.7" } } diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts new file mode 100644 index 0000000..7d5303a --- /dev/null +++ b/src/app/app.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; + +describe('App', () => { + describe('computed hasStream', () => { + it('should return false when stream_url is missing', () => { + const streamUrl = ''; + expect(!!streamUrl && streamUrl.trim().length > 0).toBe(false); + }); + + it('should return false when stream_url is whitespace', () => { + const streamUrl = ' '; + expect(!!streamUrl && streamUrl.trim().length > 0).toBe(false); + }); + + it('should return true when stream_url is a valid URL', () => { + const streamUrl = 'https://stream.example.com/live'; + expect(!!streamUrl && streamUrl.trim().length > 0).toBe(true); + }); + }); + + describe('stationConfig title formatting', () => { + it('should concatenate name_primary and name_secondary with a space', () => { + const namePrimary = 'KM'; + const nameSecondary = 'TN'; + const fullName = `${namePrimary} ${nameSecondary}`.trim(); + expect(fullName).toBe('KM TN'); + }); + + it('should trim trailing space when name_secondary is empty', () => { + const namePrimary = 'KM'; + const nameSecondary = ''; + const fullName = `${namePrimary} ${nameSecondary}`.trim(); + expect(fullName).toBe('KM'); + }); + }); +}); diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..5d0afb8 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,3 @@ +// Vitest test setup for frontend unit tests. +// Angular TestBed initialization is not required for standalone unit tests. +// Add shared mocks, global beforeEach hooks, or polyfills here as needed. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a0708a6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +/** @type {import('vitest/config').ViteUserConfig} */ +export default { + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + reporters: ['default'], + coverage: { + enabled: true, + reportsDirectory: 'coverage/frontend', + reporter: ['text', 'html', 'lcov'], + thresholds: { + lines: 0, + branches: 0, + functions: 0, + statements: 0, + }, + }, + }, +};