feat: testy

This commit is contained in:
2026-02-02 20:58:50 -08:00
parent dac875c413
commit c829ec97c4
17 changed files with 1823 additions and 9 deletions
+6
View File
@@ -15,3 +15,9 @@ Thumbs.db
# IDE
.vscode/
.idea/
# Coverage
coverage/
.coverage
htmlcov/
*.lcov
+28 -6
View File
@@ -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:
+5
View File
@@ -92,3 +92,8 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
+4
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Test package for ephemere Python scripts
+207
View File
@@ -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"]
+8 -1
View File
@@ -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"
}
}
+332
View File
@@ -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
@@ -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);
});
});
@@ -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),
);
});
});
@@ -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",
});
});
});
@@ -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<typeof mockData>(
"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<typeof mockData>(
"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<typeof mockData>(
"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();
});
});
@@ -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");
});
});
});
@@ -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<string, unknown>;
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<Array<TestItem>>(
"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<TestItem> = [];
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<Array<TestItem>>(
"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<Array<TestItem>>(
"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<Array<TestItem>>("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<Array<TestItem>>("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<Array<TestItem>>(
"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<Array<TestItem>>(
"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<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -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" });
});
});
});
@@ -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);
});
});
});
+19
View File
@@ -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__/**"],
},
},
});