diff --git a/.gitignore b/.gitignore index 910e4c3..51f43ae 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,10 @@ Thumbs.db # IDE .vscode/ -.idea/ \ No newline at end of file +.idea/ + +# Coverage +coverage/ +.coverage +htmlcov/ +*.lcov \ No newline at end of file diff --git a/Makefile b/Makefile index 0fca004..cc27384 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test clean run +.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test test-ts test-py coverage coverage-ts coverage-py clean run # Default target - show help help: @@ -12,7 +12,12 @@ help: @echo " make lint-py - Run Python linter only" @echo " make format - Format Python code" @echo " make format-check - Check Python formatting without modifying" - @echo " make test - Run tests" + @echo " make test - Run all tests (TypeScript and Python)" + @echo " make test-ts - Run TypeScript tests only" + @echo " make test-py - Run Python tests only" + @echo " make coverage - Run all tests with coverage" + @echo " make coverage-ts - Run TypeScript tests with coverage" + @echo " make coverage-py - Run Python tests with coverage" @echo " make clean - Clean build artifacts and caches" @echo "" @echo "Running scripts:" @@ -61,10 +66,27 @@ format-check: format-check-py format-check-py: cd python && uv run ruff format --check . -# Run tests -test: - @echo "No tests configured yet" - @exit 0 +# Run all tests +test: test-ts test-py + +# Run TypeScript tests +test-ts: + cd typescript && pnpm test + +# Run Python tests +test-py: + cd python && uv run pytest -v + +# Run all tests with coverage +coverage: coverage-ts coverage-py + +# Run TypeScript tests with coverage +coverage-ts: + cd typescript && pnpm test:coverage + +# Run Python tests with coverage +coverage-py: + cd python && uv run pytest --cov=. --cov-report=term-missing -v # Clean build artifacts and caches clean: diff --git a/python/pyproject.toml b/python/pyproject.toml index 9456b09..f0282cd 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -91,4 +91,9 @@ known-first-party = ["py"] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false -line-ending = "auto" \ No newline at end of file +line-ending = "auto" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] \ No newline at end of file diff --git a/python/requirements.txt b/python/requirements.txt index a366b7b..f7a7569 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,5 +1,9 @@ # Development dependencies ruff==0.14.14 +pytest==8.3.5 +pytest-mock==3.14.0 +responses==0.25.3 +pytest-asyncio==0.24.0 # Runtime dependencies requests==2.32.3 diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..6deb737 --- /dev/null +++ b/python/tests/__init__.py @@ -0,0 +1 @@ +# Test package for ephemere Python scripts diff --git a/python/tests/test_analyse_availability.py b/python/tests/test_analyse_availability.py new file mode 100644 index 0000000..caf3da3 --- /dev/null +++ b/python/tests/test_analyse_availability.py @@ -0,0 +1,207 @@ +"""Tests for analyse_availability functions. + +@copyright NHCarrigan +@license Naomi's Public License +@author Naomi Carrigan +""" + +import sys +from pathlib import Path + +import pytest + +# Add the cohort directory to the path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "cohort")) + +from analyse_availability import ( + analyze_applicant_availability, + get_utc_blocks_for_hour, + local_hour_to_utc, + parse_time_slots, + parse_utc_offset, +) + + +class TestParseUtcOffset: + """Tests for parse_utc_offset function.""" + + def test_positive_offset(self): + """Should parse positive UTC offset.""" + assert parse_utc_offset("Europe/London (UTC+0)") == 0 + assert parse_utc_offset("Europe/Paris (UTC+1)") == 1 + assert parse_utc_offset("Asia/Tokyo (UTC+9)") == 9 + + def test_negative_offset(self): + """Should parse negative UTC offset.""" + assert parse_utc_offset("America/New_York (UTC-5)") == -5 + assert parse_utc_offset("America/Los_Angeles (UTC-8)") == -8 + + def test_offset_with_minutes(self): + """Should parse offset with minutes component.""" + assert parse_utc_offset("Asia/Kolkata (UTC+5:30)") == 5.5 + assert parse_utc_offset("Asia/Kathmandu (UTC+5:45)") == 5.75 + + def test_negative_offset_with_minutes(self): + """Should parse negative offset with minutes.""" + assert parse_utc_offset("Canada/Newfoundland (UTC-3:30)") == -3.5 + + def test_no_match_returns_zero(self): + """Should return 0 when no UTC offset found.""" + assert parse_utc_offset("Unknown/Timezone") == 0 + assert parse_utc_offset("") == 0 + + +class TestParseTimeSlots: + """Tests for parse_time_slots function.""" + + def test_single_slot(self): + """Should parse a single time slot.""" + result = parse_time_slots("17:00-18:00") + assert result == [(17, 18)] + + def test_multiple_slots(self): + """Should parse multiple time slots separated by semicolon.""" + result = parse_time_slots("07:00-08:00; 19:00-20:00") + assert result == [(7, 8), (19, 20)] + + def test_na_values(self): + """Should return empty list for N/A values.""" + assert parse_time_slots("N/A") == [] + assert parse_time_slots("na") == [] + assert parse_time_slots("") == [] + + def test_wider_time_range(self): + """Should parse wider time ranges.""" + result = parse_time_slots("09:00-17:00") + assert result == [(9, 17)] + + def test_midnight_crossing_slots(self): + """Should parse time slots that approach midnight.""" + result = parse_time_slots("22:00-23:00") + assert result == [(22, 23)] + + +class TestLocalHourToUtc: + """Tests for local_hour_to_utc function.""" + + def test_positive_offset(self): + """Should convert local hour with positive UTC offset.""" + assert local_hour_to_utc(12, 1) == 11 # Noon in UTC+1 -> 11:00 UTC + assert local_hour_to_utc(0, 9) == 15 # Midnight in UTC+9 -> 15:00 UTC + + def test_negative_offset(self): + """Should convert local hour with negative UTC offset.""" + assert local_hour_to_utc(12, -5) == 17 # Noon in UTC-5 -> 17:00 UTC + assert local_hour_to_utc(20, -8) == 4 # 8pm in UTC-8 -> 4:00 UTC (next day) + + def test_wrapping_around_midnight(self): + """Should wrap around correctly at midnight.""" + assert local_hour_to_utc(1, 5) == 20 # 1am in UTC+5 -> 20:00 UTC (prev day) + assert local_hour_to_utc(23, -3) == 2 # 11pm in UTC-3 -> 2:00 UTC (next day) + + def test_zero_offset(self): + """Should return same hour for zero offset.""" + assert local_hour_to_utc(15, 0) == 15 + + +class TestGetUtcBlocksForHour: + """Tests for get_utc_blocks_for_hour function.""" + + def test_mornings_block(self): + """Should return mornings for 6-11 UTC.""" + assert "mornings" in get_utc_blocks_for_hour(6) + assert "mornings" in get_utc_blocks_for_hour(9) + assert "mornings" in get_utc_blocks_for_hour(11) + assert "mornings" not in get_utc_blocks_for_hour(12) + + def test_afternoons_block(self): + """Should return afternoons for 12-17 UTC.""" + assert "afternoons" in get_utc_blocks_for_hour(12) + assert "afternoons" in get_utc_blocks_for_hour(15) + assert "afternoons" in get_utc_blocks_for_hour(17) + assert "afternoons" not in get_utc_blocks_for_hour(18) + + def test_evenings_block(self): + """Should return evenings for 18-23 UTC.""" + assert "evenings" in get_utc_blocks_for_hour(18) + assert "evenings" in get_utc_blocks_for_hour(21) + assert "evenings" in get_utc_blocks_for_hour(23) + assert "evenings" not in get_utc_blocks_for_hour(0) + + def test_nights_block(self): + """Should return nights for 0-5 UTC.""" + assert "nights" in get_utc_blocks_for_hour(0) + assert "nights" in get_utc_blocks_for_hour(3) + assert "nights" in get_utc_blocks_for_hour(5) + assert "nights" not in get_utc_blocks_for_hour(6) + + +class TestAnalyzeApplicantAvailability: + """Tests for analyze_applicant_availability function.""" + + def test_basic_availability(self): + """Should analyze basic availability correctly.""" + day_slots = { + "Monday": [(9, 12)], + "Tuesday": [(9, 12)], + "Wednesday": [(9, 12)], + "Thursday": [], + "Friday": [], + "Saturday": [], + "Sunday": [], + } + result = analyze_applicant_availability("UTC (UTC+0)", day_slots) + + assert result["utc_offset"] == 0 + assert "mornings" in result["available_blocks"] + + def test_no_availability(self): + """Should return empty blocks when no availability.""" + day_slots = { + "Monday": [], + "Tuesday": [], + "Wednesday": [], + "Thursday": [], + "Friday": [], + "Saturday": [], + "Sunday": [], + } + result = analyze_applicant_availability("UTC (UTC+0)", day_slots) + + assert result["available_blocks"] == [] + assert result["total_unique_utc_hours"] == 0 + + def test_timezone_conversion(self): + """Should correctly convert timezones.""" + day_slots = { + "Monday": [(17, 20)], # 5pm-8pm local + "Tuesday": [(17, 20)], + "Wednesday": [(17, 20)], + "Thursday": [], + "Friday": [], + "Saturday": [], + "Sunday": [], + } + result = analyze_applicant_availability( + "America/New_York (UTC-5)", day_slots + ) + + assert result["utc_offset"] == -5 + # 5pm-8pm in UTC-5 = 22:00-01:00 UTC -> evenings/nights + assert "evenings" in result["available_blocks"] + + def test_block_count_threshold(self): + """Should only include blocks with 3+ occurrences.""" + day_slots = { + "Monday": [(9, 10)], # Only 1 hour + "Tuesday": [(9, 10)], # Only 1 hour + "Wednesday": [], + "Thursday": [], + "Friday": [], + "Saturday": [], + "Sunday": [], + } + result = analyze_applicant_availability("UTC (UTC+0)", day_slots) + + # Only 2 hours, threshold is 3 + assert "mornings" not in result["available_blocks"] diff --git a/typescript/package.json b/typescript/package.json index 0f861b8..db0393a 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -5,7 +5,10 @@ "main": "index.js", "type": "module", "scripts": { - "start": "op run --env-file=prod.env --no-masking -- tsx" + "start": "op run --env-file=prod.env --no-masking -- tsx", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "keywords": [], "author": "", @@ -24,5 +27,9 @@ "open": "11.0.0", "tsx": "4.20.5", "typescript": "5.9.2" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", + "vitest": "3.2.4" } } diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index d578136..6243b76 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -44,9 +44,20 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5)) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.3.0)(tsx@4.20.5) packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -210,10 +221,31 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.27.1': resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@es-joy/jsdoccomment@0.49.0': resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==} engines: {node: '>=16'} @@ -589,9 +621,27 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@nhcarrigan/eslint-config@5.2.0': resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==} engines: {node: '>=22', pnpm: '>=9'} @@ -672,6 +722,10 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.2': resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1136,6 +1190,15 @@ packages: resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/eslint-plugin@1.1.24': resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==} peerDependencies: @@ -1200,10 +1263,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + are-docs-informative@0.0.2: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} @@ -1251,6 +1322,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1434,12 +1508,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.210: resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -1679,6 +1759,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1722,6 +1806,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -1779,6 +1867,9 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -1938,10 +2029,32 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2013,9 +2126,19 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.18: resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2042,6 +2165,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2127,6 +2254,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2150,6 +2280,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2379,6 +2513,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -2390,6 +2527,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -2413,6 +2554,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2443,6 +2588,10 @@ packages: resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==} engines: {node: ^14.18.0 || >=16.0.0} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2658,6 +2807,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wsl-utils@0.3.0: resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==} engines: {node: '>=20'} @@ -2672,6 +2829,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -3157,8 +3319,23 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@es-joy/jsdoccomment@0.49.0': dependencies: comment-parser: 1.4.1 @@ -3451,8 +3628,31 @@ snapshots: optionalDependencies: '@types/node': 24.3.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))': dependencies: '@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0) @@ -3560,6 +3760,9 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@rollup/rollup-android-arm-eabi@4.49.0': @@ -4163,6 +4366,25 @@ snapshots: '@typescript-eslint/types': 8.41.0 eslint-visitor-keys: 4.2.1 + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.11 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.18 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@24.3.0)(tsx@4.20.5) + transitivePeerDependencies: + - supports-color + '@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))': dependencies: '@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2) @@ -4234,10 +4456,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + are-docs-informative@0.0.2: {} argparse@2.0.1: {} @@ -4313,6 +4539,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -4488,10 +4720,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.210: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -4900,6 +5136,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.2: optional: true @@ -4955,6 +5196,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -5005,6 +5255,8 @@ snapshots: hosted-git-info@2.8.9: {} + html-escaper@2.0.2: {} + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 @@ -5157,6 +5409,27 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -5166,6 +5439,14 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5226,10 +5507,22 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.18: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -5251,6 +5544,8 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + ms@2.1.3: {} mute-stream@2.0.0: {} @@ -5352,6 +5647,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5374,6 +5671,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -5627,6 +5929,8 @@ snapshots: stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@3.9.0: {} stop-iteration-iterator@1.1.0: @@ -5640,6 +5944,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -5688,6 +5998,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-indent@3.0.0: @@ -5713,6 +6027,12 @@ snapshots: '@pkgr/core': 0.1.2 tslib: 2.8.1 + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5960,6 +6280,18 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wsl-utils@0.3.0: dependencies: is-wsl: 3.1.0 diff --git a/typescript/src/crowdin/utils/__tests__/getFiles.test.ts b/typescript/src/crowdin/utils/__tests__/getFiles.test.ts new file mode 100644 index 0000000..6830025 --- /dev/null +++ b/typescript/src/crowdin/utils/__tests__/getFiles.test.ts @@ -0,0 +1,107 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getFiles } from "../getFiles.js"; + +describe("getFiles", () => { + beforeEach(() => { + vi.spyOn(global, "fetch"); + vi.spyOn(console, "log").mockImplementation(() => { + return undefined; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch files from a project", async() => { + const mockFiles = [ + { data: { id: 1, name: "file1.json", path: "/file1.json" } }, + { data: { id: 2, name: "file2.json", path: "/file2.json" } }, + ]; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: mockFiles }); + }, + } as Response); + + const result = await getFiles( + "123", + "https://api.crowdin.com/api/v2", + "test-token", + ); + + expect(result).toEqual(mockFiles); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.crowdin.com/api/v2/projects/123/files?limit=500", + { + headers: { + authorization: "Bearer test-token", + }, + }, + ); + }); + + it("should handle pagination when there are 500+ files", async() => { + const files500 = Array.from({ length: 500 }, (_, index) => { + return { data: { id: index, name: `file${String(index)}.json` } }; + }); + const filesRemaining = [ + { data: { id: 500, name: "file500.json" } }, + { data: { id: 501, name: "file501.json" } }, + ]; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: files500 }); + }, + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: filesRemaining }); + }, + } as Response); + + const result = await getFiles( + "123", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result).toHaveLength(502); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + "https://api.crowdin.com/api/v2/projects/123/files?limit=500", + expect.any(Object), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + "https://api.crowdin.com/api/v2/projects/123/files?limit=500&offset=500", + expect.any(Object), + ); + }); + + it("should return empty array when no files exist", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: [] }); + }, + } as Response); + + const result = await getFiles( + "456", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result).toEqual([]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/typescript/src/crowdin/utils/__tests__/getLanguages.test.ts b/typescript/src/crowdin/utils/__tests__/getLanguages.test.ts new file mode 100644 index 0000000..13eff50 --- /dev/null +++ b/typescript/src/crowdin/utils/__tests__/getLanguages.test.ts @@ -0,0 +1,98 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getLanguages } from "../getLanguages.js"; + +describe("getLanguages", () => { + beforeEach(() => { + vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch project and return target language IDs", async() => { + const mockResponse = { + data: { + id: 1, + name: "Test Project", + targetLanguageIds: [ "de", "fr", "es", "ja" ], + }, + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockResponse); + }, + } as Response); + + const result = await getLanguages( + "123", + "https://api.crowdin.com/api/v2", + "test-token", + ); + + expect(result).toEqual([ "de", "fr", "es", "ja" ]); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.crowdin.com/api/v2/projects/123", + { + headers: { + authorization: "Bearer test-token", + }, + }, + ); + }); + + it("should return empty array when no target languages", async() => { + const mockResponse = { + data: { + id: 2, + name: "Empty Project", + targetLanguageIds: [], + }, + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockResponse); + }, + } as Response); + + const result = await getLanguages( + "456", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result).toEqual([]); + }); + + it("should use the correct API URL", async() => { + const mockResponse = { + data: { + targetLanguageIds: [ "en" ], + }, + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockResponse); + }, + } as Response); + + await getLanguages( + "789", + "https://custom.crowdin.com/v2", + "my-token", + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://custom.crowdin.com/v2/projects/789", + expect.any(Object), + ); + }); +}); diff --git a/typescript/src/crowdin/utils/__tests__/getStrings.test.ts b/typescript/src/crowdin/utils/__tests__/getStrings.test.ts new file mode 100644 index 0000000..89ae2fd --- /dev/null +++ b/typescript/src/crowdin/utils/__tests__/getStrings.test.ts @@ -0,0 +1,139 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getStrings } from "../getStrings.js"; + +describe("getStrings", () => { + beforeEach(() => { + vi.spyOn(global, "fetch"); + vi.spyOn(console, "log").mockImplementation(() => { + return undefined; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch strings from a project", async() => { + const mockStrings = [ + { data: { id: 1, text: "Hello", identifier: "greeting" } }, + { data: { id: 2, text: "Goodbye", identifier: "farewell" } }, + ]; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: mockStrings }); + }, + } as Response); + + const result = await getStrings( + "123", + "https://api.crowdin.com/api/v2", + "test-token", + ); + + expect(result).toEqual([ + { id: 1, text: "Hello", identifier: "greeting" }, + { id: 2, text: "Goodbye", identifier: "farewell" }, + ]); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.crowdin.com/api/v2/projects/123/strings?limit=500", + { + headers: { + authorization: "Bearer test-token", + }, + }, + ); + }); + + it("should handle pagination when there are 500+ strings", async() => { + const strings500 = Array.from({ length: 500 }, (_, index) => { + return { data: { id: index, text: `String ${String(index)}` } }; + }); + const stringsRemaining = [ + { data: { id: 500, text: "String 500" } }, + { data: { id: 501, text: "String 501" } }, + ]; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: strings500 }); + }, + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: stringsRemaining }); + }, + } as Response); + + const result = await getStrings( + "123", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result).toHaveLength(502); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + "https://api.crowdin.com/api/v2/projects/123/strings?limit=500&offset=500", + expect.any(Object), + ); + }); + + it("should return empty array when no strings exist", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: [] }); + }, + } as Response); + + const result = await getStrings( + "456", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result).toEqual([]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("should unwrap the data from each string object", async() => { + const mockStrings = [ + { + data: { + context: "greeting context", + id: 1, + identifier: "hello", + isHidden: false, + text: "Hello World", + }, + }, + ]; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: mockStrings }); + }, + } as Response); + + const result = await getStrings( + "789", + "https://api.crowdin.com/api/v2", + "token", + ); + + expect(result[0]).toEqual({ + context: "greeting context", + id: 1, + identifier: "hello", + isHidden: false, + text: "Hello World", + }); + }); +}); diff --git a/typescript/src/utils/__tests__/backoffAndRetry.test.ts b/typescript/src/utils/__tests__/backoffAndRetry.test.ts new file mode 100644 index 0000000..19a42f8 --- /dev/null +++ b/typescript/src/utils/__tests__/backoffAndRetry.test.ts @@ -0,0 +1,158 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { backoffAndRetry } from "../backoffAndRetry.js"; + +describe("backoffAndRetry", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(global, "fetch"); + vi.spyOn(console, "error").mockImplementation(() => { + return undefined; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("should return JSON data on successful response", async() => { + const mockData = { success: true, data: "test" }; + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockData); + }, + ok: true, + } as Response); + + const result = await backoffAndRetry( + "https://example.com/api", + ); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/api", + {}, + ); + }); + + it("should pass options to fetch", async() => { + const mockData = { result: "success" }; + const options: RequestInit = { + headers: { authorization: "Bearer token" }, + method: "POST", + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockData); + }, + ok: true, + } as Response); + + await backoffAndRetry("https://example.com/api", options); + + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/api", + options, + ); + }); + + it("should retry after 5 seconds on 429 response", async() => { + const mockData = { result: "success" }; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + ok: false, + status: 429, + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockData); + }, + ok: true, + } as Response); + + const resultPromise = backoffAndRetry( + "https://example.com/api", + ); + + await vi.advanceTimersByTimeAsync(5000); + + const result = await resultPromise; + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("should handle multiple 429 responses with backoff", async() => { + const mockData = { result: "finally" }; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + ok: false, + status: 429, + } as Response). + mockResolvedValueOnce({ + ok: false, + status: 429, + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockData); + }, + ok: true, + } as Response); + + const resultPromise = backoffAndRetry( + "https://example.com/api", + ); + + await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(5000); + + const result = await resultPromise; + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it("should return null on non-429 error response", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + const result = await backoffAndRetry("https://example.com/api"); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); + + it("should return null on fetch error", async() => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error")); + + const result = await backoffAndRetry("https://example.com/api"); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); + + it("should return null on JSON parse error", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.reject(new Error("Invalid JSON")); + }, + ok: true, + } as Response); + + const result = await backoffAndRetry("https://example.com/api"); + + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/typescript/src/utils/__tests__/mimeType.test.ts b/typescript/src/utils/__tests__/mimeType.test.ts new file mode 100644 index 0000000..a8d911e --- /dev/null +++ b/typescript/src/utils/__tests__/mimeType.test.ts @@ -0,0 +1,242 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect } from "vitest"; +import { getMimeType } from "../mimeType.js"; + +describe("getMimeType", () => { + describe("image types", () => { + it("should return image/png for .png files", () => { + expect(getMimeType("image.png")).toBe("image/png"); + expect(getMimeType("/path/to/image.png")).toBe("image/png"); + }); + + it("should return image/jpeg for .jpg and .jpeg files", () => { + expect(getMimeType("photo.jpg")).toBe("image/jpeg"); + expect(getMimeType("photo.jpeg")).toBe("image/jpeg"); + }); + + it("should return image/gif for .gif files", () => { + expect(getMimeType("animation.gif")).toBe("image/gif"); + }); + + it("should return image/webp for .webp files", () => { + expect(getMimeType("image.webp")).toBe("image/webp"); + }); + + it("should return image/svg+xml for .svg files", () => { + expect(getMimeType("icon.svg")).toBe("image/svg+xml"); + }); + + it("should return image/bmp for .bmp files", () => { + expect(getMimeType("bitmap.bmp")).toBe("image/bmp"); + }); + + it("should return image/tiff for .tif and .tiff files", () => { + expect(getMimeType("scan.tif")).toBe("image/tiff"); + expect(getMimeType("scan.tiff")).toBe("image/tiff"); + }); + + it("should return image/x-icon for .ico files", () => { + expect(getMimeType("favicon.ico")).toBe("image/x-icon"); + }); + }); + + describe("video types", () => { + it("should return video/mp4 for .mp4 files", () => { + expect(getMimeType("video.mp4")).toBe("video/mp4"); + }); + + it("should return video/webm for .webm files", () => { + expect(getMimeType("video.webm")).toBe("video/webm"); + }); + + it("should return video/x-msvideo for .avi files", () => { + expect(getMimeType("video.avi")).toBe("video/x-msvideo"); + }); + + it("should return video/quicktime for .mov files", () => { + expect(getMimeType("video.mov")).toBe("video/quicktime"); + }); + + it("should return video/x-matroska for .mkv files", () => { + expect(getMimeType("video.mkv")).toBe("video/x-matroska"); + }); + }); + + describe("audio types", () => { + it("should return audio/mpeg for .mp3 files", () => { + expect(getMimeType("song.mp3")).toBe("audio/mpeg"); + }); + + it("should return audio/wav for .wav files", () => { + expect(getMimeType("sound.wav")).toBe("audio/wav"); + }); + + it("should return audio/ogg for .ogg files", () => { + expect(getMimeType("audio.ogg")).toBe("audio/ogg"); + }); + + it("should return audio/aac for .aac files", () => { + expect(getMimeType("audio.aac")).toBe("audio/aac"); + }); + + it("should return audio/flac for .flac files", () => { + expect(getMimeType("music.flac")).toBe("audio/flac"); + }); + }); + + describe("document types", () => { + it("should return application/pdf for .pdf files", () => { + expect(getMimeType("document.pdf")).toBe("application/pdf"); + }); + + it("should return application/json for .json files", () => { + expect(getMimeType("data.json")).toBe("application/json"); + }); + + it("should return text/plain for .txt files", () => { + expect(getMimeType("readme.txt")).toBe("text/plain"); + }); + + it("should return text/markdown for .md files", () => { + expect(getMimeType("README.md")).toBe("text/markdown"); + }); + + it("should return text/html for .html and .htm files", () => { + expect(getMimeType("page.html")).toBe("text/html"); + expect(getMimeType("page.htm")).toBe("text/html"); + }); + + it("should return text/css for .css files", () => { + expect(getMimeType("styles.css")).toBe("text/css"); + }); + + it("should return text/javascript for .js files", () => { + expect(getMimeType("script.js")).toBe("text/javascript"); + }); + + it("should return text/csv for .csv files", () => { + expect(getMimeType("data.csv")).toBe("text/csv"); + }); + + it("should return application/xml for .xml files", () => { + expect(getMimeType("data.xml")).toBe("application/xml"); + }); + }); + + describe("Microsoft Office types", () => { + it("should return correct MIME type for .doc files", () => { + expect(getMimeType("document.doc")).toBe("application/msword"); + }); + + it("should return correct MIME type for .docx files", () => { + expect(getMimeType("document.docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + }); + + it("should return correct MIME type for .xls files", () => { + expect(getMimeType("spreadsheet.xls")).toBe("application/vnd.ms-excel"); + }); + + it("should return correct MIME type for .xlsx files", () => { + expect(getMimeType("spreadsheet.xlsx")).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + }); + + it("should return correct MIME type for .ppt files", () => { + expect(getMimeType("presentation.ppt")).toBe( + "application/vnd.ms-powerpoint", + ); + }); + + it("should return correct MIME type for .pptx files", () => { + expect(getMimeType("presentation.pptx")).toBe( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ); + }); + }); + + describe("font types", () => { + it("should return font/ttf for .ttf files", () => { + expect(getMimeType("font.ttf")).toBe("font/ttf"); + }); + + it("should return font/otf for .otf files", () => { + expect(getMimeType("font.otf")).toBe("font/otf"); + }); + + it("should return font/woff for .woff files", () => { + expect(getMimeType("font.woff")).toBe("font/woff"); + }); + + it("should return font/woff2 for .woff2 files", () => { + expect(getMimeType("font.woff2")).toBe("font/woff2"); + }); + + it("should return application/vnd.ms-fontobject for .eot files", () => { + expect(getMimeType("font.eot")).toBe("application/vnd.ms-fontobject"); + }); + }); + + describe("archive types", () => { + it("should return application/zip for .zip files", () => { + expect(getMimeType("archive.zip")).toBe("application/zip"); + }); + + it("should return application/gzip for .gz files", () => { + expect(getMimeType("archive.gz")).toBe("application/gzip"); + }); + + it("should return application/x-tar for .tar files", () => { + expect(getMimeType("archive.tar")).toBe("application/x-tar"); + }); + + it("should return application/x-rar-compressed for .rar files", () => { + expect(getMimeType("archive.rar")).toBe("application/x-rar-compressed"); + }); + + it("should return application/x-7z-compressed for .7z files", () => { + expect(getMimeType("archive.7z")).toBe("application/x-7z-compressed"); + }); + }); + + describe("case insensitivity", () => { + it("should handle uppercase extensions", () => { + expect(getMimeType("IMAGE.PNG")).toBe("image/png"); + expect(getMimeType("VIDEO.MP4")).toBe("video/mp4"); + }); + + it("should handle mixed case extensions", () => { + expect(getMimeType("file.JpG")).toBe("image/jpeg"); + expect(getMimeType("file.Mp3")).toBe("audio/mpeg"); + }); + }); + + describe("unknown types", () => { + it("should return undefined for unknown extensions", () => { + expect(getMimeType("file.xyz")).toBeUndefined(); + expect(getMimeType("file.unknown")).toBeUndefined(); + }); + + it("should return undefined for files without extensions", () => { + expect(getMimeType("filename")).toBeUndefined(); + }); + }); + + describe("path handling", () => { + it("should handle full paths", () => { + expect(getMimeType("/home/user/documents/image.png")).toBe("image/png"); + expect(getMimeType("./relative/path/video.mp4")).toBe("video/mp4"); + }); + + it("should handle filenames with multiple dots", () => { + expect(getMimeType("file.name.with.dots.png")).toBe("image/png"); + expect(getMimeType("archive.tar.gz")).toBe("application/gzip"); + }); + }); +}); diff --git a/typescript/src/utils/__tests__/paginatedFetch.test.ts b/typescript/src/utils/__tests__/paginatedFetch.test.ts new file mode 100644 index 0000000..53d883c --- /dev/null +++ b/typescript/src/utils/__tests__/paginatedFetch.test.ts @@ -0,0 +1,258 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { paginatedFetch } from "../paginatedFetch.js"; + +type TestItem = Record; + +describe("paginatedFetch", () => { + beforeEach(() => { + vi.spyOn(global, "fetch"); + vi.spyOn(console, "log").mockImplementation(() => { + return undefined; + }); + vi.spyOn(console, "error").mockImplementation(() => { + return undefined; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch a single page when results fit in one request", async() => { + const mockData = [ + { id: 1, name: "item1" }, + { id: 2, name: "item2" }, + ]; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(mockData); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve([]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + const result = await paginatedFetch>( + "https://example.com/api", + 10, + ); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/api?limit=10&page=1&offset=0", + {}, + ); + }); + + it("should handle multiple pages", async() => { + const page1 = [ + { id: 1 }, + { id: 2 }, + ]; + const page2 = [ + { id: 3 }, + { id: 4 }, + ]; + const page3: Array = []; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(page1); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(page2); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve(page3); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + const result = await paginatedFetch>( + "https://example.com/api", + 2, + ); + + expect(result).toEqual([ ...page1, ...page2 ]); + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + "https://example.com/api?limit=2&page=1&offset=0", + {}, + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + "https://example.com/api?limit=2&page=2&offset=2", + {}, + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + "https://example.com/api?limit=2&page=3&offset=4", + {}, + ); + }); + + it("should pass options to fetch", async() => { + const options: RequestInit = { + headers: { authorization: "Bearer token" }, + }; + + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve([{ id: 1 }]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve([]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + await paginatedFetch>( + "https://example.com/api", + 10, + options, + ); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + options, + ); + }); + + it("should throw on first page error", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + + await expect( + paginatedFetch>("https://example.com/api", 10), + ).rejects.toThrow( + "Failed to fetch https://example.com/api?limit=10&page=1&offset=0: 500 Internal Server Error", + ); + }); + + it("should throw if first page is not an array", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ data: "not an array" }); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + await expect( + paginatedFetch>("https://example.com/api", 10), + ).rejects.toThrow("Expected array response but got object"); + }); + + it("should stop pagination on subsequent page error", async() => { + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve([{ id: 1 }]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + + const result = await paginatedFetch>( + "https://example.com/api", + 10, + ); + + expect(result).toEqual([{ id: 1 }]); + expect(console.error).toHaveBeenCalled(); + }); + + it("should stop pagination if subsequent page is not an array", async() => { + vi.mocked(global.fetch). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve([{ id: 1 }]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response). + mockResolvedValueOnce({ + json: () => { + return Promise.resolve({ notAnArray: true }); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + const result = await paginatedFetch>( + "https://example.com/api", + 10, + ); + + expect(result).toEqual([{ id: 1 }]); + expect(console.error).toHaveBeenCalled(); + }); + + it("should handle empty first page", async() => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + json: () => { + return Promise.resolve([]); + }, + ok: true, + status: 200, + statusText: "OK", + } as Response); + + const result = await paginatedFetch>( + "https://example.com/api", + 10, + ); + + expect(result).toEqual([]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/typescript/src/utils/__tests__/serialiseJsonOrError.test.ts b/typescript/src/utils/__tests__/serialiseJsonOrError.test.ts new file mode 100644 index 0000000..a417bd8 --- /dev/null +++ b/typescript/src/utils/__tests__/serialiseJsonOrError.test.ts @@ -0,0 +1,128 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect } from "vitest"; +import { serialiseJsonOrError } from "../serialiseJsonOrError.js"; + +describe("serialiseJsonOrError", () => { + describe("valid JSON parsing", () => { + it("should parse a simple object", () => { + const result = serialiseJsonOrError('{"key": "value"}'); + expect(result).toEqual({ key: "value" }); + }); + + it("should parse an object with multiple properties", () => { + const result = serialiseJsonOrError( + '{"name": "test", "count": 42, "active": true}', + ); + expect(result).toEqual({ name: "test", count: 42, active: true }); + }); + + it("should parse nested objects", () => { + const result = serialiseJsonOrError( + '{"outer": {"inner": {"deep": "value"}}}', + ); + expect(result).toEqual({ outer: { inner: { deep: "value" } } }); + }); + + it("should parse objects with arrays", () => { + const result = serialiseJsonOrError('{"items": [1, 2, 3]}'); + expect(result).toEqual({ items: [ 1, 2, 3 ] }); + }); + + it("should parse objects with null values", () => { + const result = serialiseJsonOrError('{"value": null}'); + expect(result).toEqual({ value: null }); + }); + + it("should parse an empty object", () => { + const result = serialiseJsonOrError("{}"); + expect(result).toEqual({}); + }); + + it("should parse objects with special characters in strings", () => { + const result = serialiseJsonOrError('{"text": "hello\\nworld"}'); + expect(result).toEqual({ text: "hello\nworld" }); + }); + + it("should parse objects with unicode characters", () => { + const result = serialiseJsonOrError('{"emoji": "🎉", "text": "日本語"}'); + expect(result).toEqual({ emoji: "🎉", text: "日本語" }); + }); + + it("should parse objects with numeric keys (as strings)", () => { + const result = serialiseJsonOrError('{"123": "numeric key"}'); + expect(result).toEqual({ "123": "numeric key" }); + }); + }); + + describe("invalid JSON handling", () => { + it("should return null for invalid JSON syntax", () => { + expect(serialiseJsonOrError("{invalid}")).toBeNull(); + expect(serialiseJsonOrError("not json")).toBeNull(); + }); + + it("should return null for unclosed braces", () => { + expect(serialiseJsonOrError('{"key": "value"')).toBeNull(); + expect(serialiseJsonOrError('{"key": {')).toBeNull(); + }); + + it("should return null for trailing commas", () => { + expect(serialiseJsonOrError('{"key": "value",}')).toBeNull(); + }); + + it("should return null for single quotes", () => { + expect(serialiseJsonOrError("{'key': 'value'}")).toBeNull(); + }); + + it("should return null for unquoted keys", () => { + expect(serialiseJsonOrError('{key: "value"}')).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(serialiseJsonOrError("")).toBeNull(); + }); + + it("should return null for whitespace only", () => { + expect(serialiseJsonOrError(" ")).toBeNull(); + }); + }); + + describe("edge cases", () => { + it("should parse a plain array (returns as object)", () => { + const result = serialiseJsonOrError("[1, 2, 3]"); + expect(result).toEqual([ 1, 2, 3 ]); + }); + + it("should parse primitive values", () => { + expect(serialiseJsonOrError("42")).toBe(42); + expect(serialiseJsonOrError('"string"')).toBe("string"); + expect(serialiseJsonOrError("true")).toBe(true); + expect(serialiseJsonOrError("false")).toBe(false); + expect(serialiseJsonOrError("null")).toBeNull(); + }); + + it("should handle very large numbers", () => { + const result = serialiseJsonOrError('{"big": 9007199254740991}'); + expect(result).toEqual({ big: 9007199254740991 }); + }); + + it("should handle scientific notation", () => { + const result = serialiseJsonOrError('{"sci": 1.23e10}'); + expect(result).toEqual({ sci: 1.23e10 }); + }); + + it("should handle deeply nested structures", () => { + const deepJson = '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}'; + const result = serialiseJsonOrError(deepJson); + expect(result).toEqual({ a: { b: { c: { d: { e: "deep" } } } } }); + }); + + it("should handle JSON with whitespace", () => { + const result = serialiseJsonOrError(' { "key" : "value" } '); + expect(result).toEqual({ key: "value" }); + }); + }); +}); diff --git a/typescript/src/utils/__tests__/sleep.test.ts b/typescript/src/utils/__tests__/sleep.test.ts new file mode 100644 index 0000000..3ae80a1 --- /dev/null +++ b/typescript/src/utils/__tests__/sleep.test.ts @@ -0,0 +1,81 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { sleep } from "../sleep.js"; + +describe("sleep", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve after the specified time", async() => { + const sleepPromise = sleep(1000); + + vi.advanceTimersByTime(1000); + + await expect(sleepPromise).resolves.toBeUndefined(); + }); + + it("should not resolve before the specified time", async() => { + let resolved = false; + const sleepPromise = sleep(1000).then(() => { + resolved = true; + }); + + vi.advanceTimersByTime(999); + await Promise.resolve(); + + expect(resolved).toBe(false); + + vi.advanceTimersByTime(1); + await sleepPromise; + + expect(resolved).toBe(true); + }); + + it("should handle zero milliseconds", async() => { + const sleepPromise = sleep(0); + + vi.advanceTimersByTime(0); + + await expect(sleepPromise).resolves.toBeUndefined(); + }); + + it("should handle small delays", async() => { + const sleepPromise = sleep(10); + + vi.advanceTimersByTime(10); + + await expect(sleepPromise).resolves.toBeUndefined(); + }); + + it("should handle large delays", async() => { + const sleepPromise = sleep(60000); + + vi.advanceTimersByTime(60000); + + await expect(sleepPromise).resolves.toBeUndefined(); + }); + + describe("real timer tests", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("should actually wait with real timers (short delay)", async() => { + const start = Date.now(); + await sleep(50); + const elapsed = Date.now() - start; + + expect(elapsed).toBeGreaterThanOrEqual(45); + expect(elapsed).toBeLessThan(150); + }); + }); +}); diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts new file mode 100644 index 0000000..a524cc3 --- /dev/null +++ b/typescript/vitest.config.ts @@ -0,0 +1,19 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + exclude: ["node_modules/", "**/__tests__/**"], + }, + }, +});