testing
This commit is contained in:
@@ -4,7 +4,13 @@
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(curl -s http://127.0.0.1:8000/api/users/by-email/jeet.debnath2004@gmail.com)",
|
||||
"Bash(ipconfig getifaddr:*)"
|
||||
"Bash(ipconfig getifaddr:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(/Users/jeet/Library/Python/3.9/bin/pytest -v 2>&1)",
|
||||
"Bash(conda run:*)",
|
||||
"Bash(git rm:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -15,6 +15,20 @@ dist-ssr
|
||||
.env.*
|
||||
.env.local
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -1,7 +1,12 @@
|
||||
fastapi==0.104.1
|
||||
fastapi>=0.115.0
|
||||
uvicorn==0.24.0
|
||||
pymongo==4.6.0
|
||||
pydantic==2.5.0
|
||||
pydantic>=2.5.0
|
||||
python-dotenv==1.0.0
|
||||
pydantic-settings==2.1.0
|
||||
pydantic-settings>=2.1.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
httpx>=0.25.0
|
||||
mongomock>=4.1.2
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ from models import JournalEntryCreate, JournalEntryUpdate, JournalEntry, Entries
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from bson import ObjectId
|
||||
from bson.errors import InvalidId
|
||||
from utils import format_ist_timestamp
|
||||
|
||||
router = APIRouter()
|
||||
@@ -50,7 +51,10 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
# Verify user exists
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
@@ -91,9 +95,6 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to create entry: {str(e)}")
|
||||
|
||||
@@ -113,7 +114,10 @@ async def get_user_entries(
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
# Verify user exists
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
@@ -142,10 +146,9 @@ async def get_user_entries(
|
||||
"hasMore": has_more
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch entries: {str(e)}")
|
||||
|
||||
@@ -158,7 +161,10 @@ async def get_entry(user_id: str, entry_id: str):
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
entry_oid = ObjectId(entry_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
|
||||
try:
|
||||
entry = db.entries.find_one({
|
||||
"_id": entry_oid,
|
||||
"userId": user_oid
|
||||
@@ -168,9 +174,9 @@ async def get_entry(user_id: str, entry_id: str):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
return _format_entry(entry)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch entry: {str(e)}")
|
||||
|
||||
@@ -183,7 +189,10 @@ async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpda
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
entry_oid = ObjectId(entry_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
|
||||
try:
|
||||
update_data = entry_data.model_dump(exclude_unset=True)
|
||||
update_data["updatedAt"] = datetime.utcnow()
|
||||
|
||||
@@ -206,9 +215,9 @@ async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpda
|
||||
# Fetch and return updated entry
|
||||
entry = db.entries.find_one({"_id": entry_oid})
|
||||
return _format_entry(entry)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update entry: {str(e)}")
|
||||
|
||||
@@ -221,7 +230,10 @@ async def delete_entry(user_id: str, entry_id: str):
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
entry_oid = ObjectId(entry_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
|
||||
try:
|
||||
result = db.entries.delete_one({
|
||||
"_id": entry_oid,
|
||||
"userId": user_oid
|
||||
@@ -231,9 +243,9 @@ async def delete_entry(user_id: str, entry_id: str):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
return {"message": "Entry deleted successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to delete entry: {str(e)}")
|
||||
|
||||
@@ -249,7 +261,10 @@ async def get_entries_by_date(user_id: str, date_str: str):
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
# Parse date
|
||||
target_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
next_date = target_date + timedelta(days=1)
|
||||
@@ -274,10 +289,9 @@ async def get_entries_by_date(user_id: str, date_str: str):
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch entries: {str(e)}")
|
||||
|
||||
@@ -293,7 +307,10 @@ async def get_entries_by_month(user_id: str, year: int, month: int, limit: int =
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
if not (1 <= month <= 12):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Month must be between 1 and 12")
|
||||
@@ -325,10 +342,9 @@ async def get_entries_by_month(user_id: str, year: int, month: int, limit: int =
|
||||
}
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid year or month")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch entries: {str(e)}")
|
||||
|
||||
@@ -347,6 +363,8 @@ async def convert_utc_to_ist(data: dict):
|
||||
"utc": utc_timestamp,
|
||||
"ist": ist_timestamp
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""User management routes"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pymongo.errors import DuplicateKeyError, WriteError
|
||||
from db import get_database
|
||||
from models import UserCreate, UserUpdate, User
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from bson import ObjectId
|
||||
from bson.errors import InvalidId
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -56,6 +56,8 @@ async def register_user(user_data: UserCreate):
|
||||
"updatedAt": user["updatedAt"].isoformat(),
|
||||
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Registration failed: {str(e)}")
|
||||
@@ -80,6 +82,8 @@ async def get_user_by_email(email: str):
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat()
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch user: {str(e)}")
|
||||
@@ -91,7 +95,12 @@ async def get_user_by_id(user_id: str):
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user = db.users.find_one({"_id": ObjectId(user_id)})
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
@@ -104,10 +113,9 @@ async def get_user_by_id(user_id: str):
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat()
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch user: {str(e)}")
|
||||
|
||||
@@ -117,13 +125,18 @@ async def update_user(user_id: str, user_data: UserUpdate):
|
||||
"""Update user profile."""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
# Prepare update data (exclude None values)
|
||||
update_data = user_data.model_dump(exclude_unset=True)
|
||||
update_data["updatedAt"] = datetime.utcnow()
|
||||
|
||||
result = db.users.update_one(
|
||||
{"_id": ObjectId(user_id)},
|
||||
{"_id": user_oid},
|
||||
{"$set": update_data}
|
||||
)
|
||||
|
||||
@@ -131,7 +144,7 @@ async def update_user(user_id: str, user_data: UserUpdate):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Fetch and return updated user
|
||||
user = db.users.find_one({"_id": ObjectId(user_id)})
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
return {
|
||||
"id": str(user["_id"]),
|
||||
"email": user["email"],
|
||||
@@ -142,10 +155,9 @@ async def update_user(user_id: str, user_data: UserUpdate):
|
||||
"updatedAt": user["updatedAt"].isoformat(),
|
||||
"message": "User updated successfully"
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}")
|
||||
|
||||
|
||||
@@ -154,33 +166,27 @@ async def delete_user(user_id: str):
|
||||
"""Delete user account and all associated data."""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
||||
|
||||
try:
|
||||
# Delete user
|
||||
user_result = db.users.delete_one({"_id": ObjectId(user_id)})
|
||||
user_result = db.users.delete_one({"_id": user_oid})
|
||||
if user_result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Delete all user's entries
|
||||
entry_result = db.entries.delete_many({"userId": ObjectId(user_id)})
|
||||
entry_result = db.entries.delete_many({"userId": user_oid})
|
||||
|
||||
return {
|
||||
"message": "User deleted successfully",
|
||||
"user_deleted": user_result.deleted_count,
|
||||
"entries_deleted": entry_result.deleted_count
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
if "invalid ObjectId" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid user ID format")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Deletion failed: {str(e)}")
|
||||
|
||||
# Delete all entries by user
|
||||
db.entries.delete_many({"userId": user_id})
|
||||
|
||||
# Delete user settings
|
||||
db.settings.delete_one({"userId": user_id})
|
||||
|
||||
return {"message": "User and associated data deleted"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
41
backend/tests/conftest.py
Normal file
41
backend/tests/conftest.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Shared pytest fixtures for all backend tests.
|
||||
|
||||
Strategy:
|
||||
- Use mongomock to create an in-memory MongoDB per test.
|
||||
- Directly set MongoDB.db to the mock database so get_database() returns it.
|
||||
- Patch MongoDB.connect_db / close_db so FastAPI's lifespan doesn't try
|
||||
to connect to a real MongoDB server.
|
||||
"""
|
||||
import pytest
|
||||
import mongomock
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Fresh in-memory MongoDB database for each test."""
|
||||
client = mongomock.MongoClient()
|
||||
return client["test_grateful_journal"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_db):
|
||||
"""
|
||||
FastAPI TestClient with MongoDB replaced by an in-memory mock.
|
||||
|
||||
Yields (TestClient, mock_db) so tests can inspect the database directly.
|
||||
"""
|
||||
from db import MongoDB
|
||||
from main import app
|
||||
|
||||
with (
|
||||
patch.object(MongoDB, "connect_db"),
|
||||
patch.object(MongoDB, "close_db"),
|
||||
):
|
||||
MongoDB.db = mock_db
|
||||
with TestClient(app) as c:
|
||||
yield c, mock_db
|
||||
|
||||
MongoDB.db = None
|
||||
454
backend/tests/test_entries.py
Normal file
454
backend/tests/test_entries.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""Tests for journal entry endpoints (/api/entries/*)."""
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_ENCRYPTION = {
|
||||
"encrypted": True,
|
||||
"ciphertext": "dGVzdF9jaXBoZXJ0ZXh0", # base64("test_ciphertext")
|
||||
"nonce": "dGVzdF9ub25jZQ==", # base64("test_nonce")
|
||||
"algorithm": "XSalsa20-Poly1305",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(client):
|
||||
"""Register and return a test user."""
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "entry_test@example.com"})
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry(client, user):
|
||||
"""Create and return a test entry."""
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/entries/{user_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateEntry:
|
||||
def test_create_encrypted_entry_returns_200(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_create_entry_returns_id_and_message(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["message"] == "Entry created successfully"
|
||||
|
||||
def test_create_entry_with_mood(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"mood": "grateful",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_create_entry_with_invalid_mood_returns_422(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"mood": "ecstatic", # Not in MoodEnum
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_entry_with_tags(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"tags": ["family", "gratitude"],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_create_entry_missing_ciphertext_returns_400(self, client, user):
|
||||
"""Encryption metadata without ciphertext must be rejected."""
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": {
|
||||
"encrypted": True,
|
||||
"nonce": "bm9uY2U=",
|
||||
"algorithm": "XSalsa20-Poly1305",
|
||||
# ciphertext intentionally missing
|
||||
}
|
||||
})
|
||||
# Pydantic requires ciphertext field → 422
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_entry_encryption_missing_nonce_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": {
|
||||
"encrypted": True,
|
||||
"ciphertext": "dGVzdA==",
|
||||
"algorithm": "XSalsa20-Poly1305",
|
||||
# nonce intentionally missing
|
||||
}
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_entry_for_nonexistent_user_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/507f1f77bcf86cd799439011", json={"encryption": VALID_ENCRYPTION})
|
||||
assert response.status_code == 404
|
||||
assert "User not found" in response.json()["detail"]
|
||||
|
||||
def test_create_entry_with_invalid_user_id_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/not-a-valid-id", json={"encryption": VALID_ENCRYPTION})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_create_entry_with_specific_entry_date(self, client, user):
|
||||
c, _ = client
|
||||
response = c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"entryDate": "2024-06-15T00:00:00",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/entries/{user_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetUserEntries:
|
||||
def test_returns_entries_and_pagination(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "entries" in data
|
||||
assert "pagination" in data
|
||||
|
||||
def test_returns_entry_that_was_created(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}")
|
||||
entries = response.json()["entries"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == entry["id"]
|
||||
|
||||
def test_entry_includes_encryption_metadata(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}")
|
||||
fetched_entry = response.json()["entries"][0]
|
||||
assert fetched_entry["encryption"]["ciphertext"] == VALID_ENCRYPTION["ciphertext"]
|
||||
assert fetched_entry["encryption"]["nonce"] == VALID_ENCRYPTION["nonce"]
|
||||
|
||||
def test_empty_list_when_no_entries(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["entries"] == []
|
||||
assert response.json()["pagination"]["total"] == 0
|
||||
|
||||
def test_pagination_limit(self, client, user):
|
||||
c, _ = client
|
||||
for _ in range(5):
|
||||
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
response = c.get(f"/api/entries/{user['id']}?limit=2&skip=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["entries"]) == 2
|
||||
assert data["pagination"]["hasMore"] is True
|
||||
assert data["pagination"]["total"] == 5
|
||||
|
||||
def test_pagination_skip(self, client, user):
|
||||
c, _ = client
|
||||
for _ in range(4):
|
||||
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
response = c.get(f"/api/entries/{user['id']}?limit=10&skip=3")
|
||||
assert len(response.json()["entries"]) == 1
|
||||
|
||||
def test_pagination_has_more_false_at_end(self, client, user):
|
||||
c, _ = client
|
||||
for _ in range(3):
|
||||
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
|
||||
response = c.get(f"/api/entries/{user['id']}?limit=10&skip=0")
|
||||
assert response.json()["pagination"]["hasMore"] is False
|
||||
|
||||
def test_nonexistent_user_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/entries/507f1f77bcf86cd799439011")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_invalid_user_id_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/entries/bad-id")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/entries/{user_id}/{entry_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetSingleEntry:
|
||||
def test_returns_entry_by_id(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == entry["id"]
|
||||
|
||||
def test_returned_entry_has_encryption_field(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
data = response.json()
|
||||
assert "encryption" in data
|
||||
assert data["encryption"]["ciphertext"] == VALID_ENCRYPTION["ciphertext"]
|
||||
|
||||
def test_entry_belongs_to_correct_user(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
assert response.json()["userId"] == user["id"]
|
||||
|
||||
def test_entry_from_different_user_returns_404(self, client, user, entry):
|
||||
"""User isolation: another user cannot access this entry."""
|
||||
c, _ = client
|
||||
other = c.post("/api/users/register", json={"email": "other@example.com"}).json()
|
||||
response = c.get(f"/api/entries/{other['id']}/{entry['id']}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_nonexistent_entry_returns_404(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_invalid_entry_id_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/not-valid-id")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_user_id_returns_400(self, client, entry):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/bad-user-id/{entry['id']}")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/entries/{user_id}/{entry_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateEntry:
|
||||
def test_update_mood(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "happy"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["mood"] == "happy"
|
||||
|
||||
def test_update_encryption_ciphertext(self, client, user, entry):
|
||||
c, _ = client
|
||||
new_enc = {**VALID_ENCRYPTION, "ciphertext": "bmV3Y2lwaGVydGV4dA=="}
|
||||
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"encryption": new_enc})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["encryption"]["ciphertext"] == "bmV3Y2lwaGVydGV4dA=="
|
||||
|
||||
def test_update_persists(self, client, user, entry):
|
||||
c, _ = client
|
||||
c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "sad"})
|
||||
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
assert response.json()["mood"] == "sad"
|
||||
|
||||
def test_update_invalid_mood_returns_422(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "furious"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_update_nonexistent_entry_returns_404(self, client, user):
|
||||
c, _ = client
|
||||
response = c.put(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099", json={"mood": "happy"})
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_invalid_entry_id_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.put(f"/api/entries/{user['id']}/bad-id", json={"mood": "happy"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/entries/{user_id}/{entry_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeleteEntry:
|
||||
def test_delete_entry_returns_200(self, client, user, entry):
|
||||
c, _ = client
|
||||
response = c.delete(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
assert response.status_code == 200
|
||||
assert "deleted" in response.json()["message"].lower()
|
||||
|
||||
def test_deleted_entry_is_not_retrievable(self, client, user, entry):
|
||||
c, _ = client
|
||||
c.delete(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_deleted_entry_not_in_list(self, client, user, entry):
|
||||
c, _ = client
|
||||
c.delete(f"/api/entries/{user['id']}/{entry['id']}")
|
||||
response = c.get(f"/api/entries/{user['id']}")
|
||||
assert response.json()["entries"] == []
|
||||
|
||||
def test_delete_entry_wrong_user_returns_404(self, client, user, entry):
|
||||
"""User isolation: another user cannot delete this entry."""
|
||||
c, _ = client
|
||||
other = c.post("/api/users/register", json={"email": "other_del@example.com"}).json()
|
||||
response = c.delete(f"/api/entries/{other['id']}/{entry['id']}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_nonexistent_entry_returns_404(self, client, user):
|
||||
c, _ = client
|
||||
response = c.delete(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_invalid_entry_id_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.delete(f"/api/entries/{user['id']}/bad-id")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/entries/{user_id}/by-date/{date_str}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetEntriesByDate:
|
||||
def test_returns_entry_for_matching_date(self, client, user):
|
||||
c, _ = client
|
||||
c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"entryDate": "2024-06-15T00:00:00",
|
||||
})
|
||||
response = c.get(f"/api/entries/{user['id']}/by-date/2024-06-15")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["date"] == "2024-06-15"
|
||||
|
||||
def test_returns_empty_for_date_with_no_entries(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-date/2020-01-01")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 0
|
||||
|
||||
def test_does_not_return_entries_from_other_dates(self, client, user):
|
||||
c, _ = client
|
||||
c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"entryDate": "2024-06-15T00:00:00",
|
||||
})
|
||||
response = c.get(f"/api/entries/{user['id']}/by-date/2024-06-16") # Next day
|
||||
assert response.json()["count"] == 0
|
||||
|
||||
def test_invalid_date_format_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-date/not-a-date")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_date_13th_month_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-date/2024-13-01")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_user_id_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/entries/bad-id/by-date/2024-06-15")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/entries/{user_id}/by-month/{year}/{month}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetEntriesByMonth:
|
||||
def test_returns_entries_for_matching_month(self, client, user):
|
||||
c, _ = client
|
||||
c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"entryDate": "2024-06-15T00:00:00",
|
||||
})
|
||||
response = c.get(f"/api/entries/{user['id']}/by-month/2024/6")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["year"] == 2024
|
||||
assert data["month"] == 6
|
||||
|
||||
def test_does_not_return_entries_from_other_months(self, client, user):
|
||||
c, _ = client
|
||||
c.post(f"/api/entries/{user['id']}", json={
|
||||
"encryption": VALID_ENCRYPTION,
|
||||
"entryDate": "2024-05-10T00:00:00", # May, not June
|
||||
})
|
||||
response = c.get(f"/api/entries/{user['id']}/by-month/2024/6")
|
||||
assert response.json()["count"] == 0
|
||||
|
||||
def test_december_january_rollover_works(self, client, user):
|
||||
"""Month 12 boundary (year+1 rollover) must not crash."""
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-month/2024/12")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_invalid_month_0_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-month/2024/0")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_month_13_returns_400(self, client, user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/entries/{user['id']}/by-month/2024/13")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_user_id_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/entries/bad-id/by-month/2024/6")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/entries/convert-timestamp/utc-to-ist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConvertTimestamp:
|
||||
def test_converts_utc_z_suffix_to_ist(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "utc" in data
|
||||
assert "ist" in data
|
||||
assert "+05:30" in data["ist"]
|
||||
|
||||
def test_ist_is_5h30m_ahead_of_utc(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
})
|
||||
assert "05:30:00+05:30" in response.json()["ist"]
|
||||
|
||||
def test_missing_timestamp_field_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_timestamp_string_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
|
||||
"timestamp": "not-a-date"
|
||||
})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_original_utc_in_response(self, client):
|
||||
c, _ = client
|
||||
utc = "2024-06-15T12:00:00Z"
|
||||
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={"timestamp": utc})
|
||||
assert response.json()["utc"] == utc
|
||||
196
backend/tests/test_models.py
Normal file
196
backend/tests/test_models.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Tests for Pydantic data models (backend/models.py)."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from models import (
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
EncryptionMetadata,
|
||||
JournalEntryCreate,
|
||||
JournalEntryUpdate,
|
||||
MoodEnum,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UserCreate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUserCreate:
|
||||
def test_requires_email(self):
|
||||
with pytest.raises(ValidationError):
|
||||
UserCreate()
|
||||
|
||||
def test_valid_email_only(self):
|
||||
user = UserCreate(email="test@example.com")
|
||||
assert user.email == "test@example.com"
|
||||
|
||||
def test_display_name_is_optional(self):
|
||||
user = UserCreate(email="test@example.com")
|
||||
assert user.displayName is None
|
||||
|
||||
def test_photo_url_is_optional(self):
|
||||
user = UserCreate(email="test@example.com")
|
||||
assert user.photoURL is None
|
||||
|
||||
def test_all_fields(self):
|
||||
user = UserCreate(
|
||||
email="test@example.com",
|
||||
displayName="Alice",
|
||||
photoURL="https://example.com/pic.jpg",
|
||||
)
|
||||
assert user.displayName == "Alice"
|
||||
assert user.photoURL == "https://example.com/pic.jpg"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UserUpdate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUserUpdate:
|
||||
def test_all_fields_optional(self):
|
||||
update = UserUpdate()
|
||||
assert update.displayName is None
|
||||
assert update.photoURL is None
|
||||
assert update.theme is None
|
||||
|
||||
def test_update_only_theme(self):
|
||||
update = UserUpdate(theme="dark")
|
||||
assert update.theme == "dark"
|
||||
assert update.displayName is None
|
||||
|
||||
def test_update_only_display_name(self):
|
||||
update = UserUpdate(displayName="New Name")
|
||||
assert update.displayName == "New Name"
|
||||
assert update.theme is None
|
||||
|
||||
def test_model_dump_excludes_unset(self):
|
||||
update = UserUpdate(theme="dark")
|
||||
dumped = update.model_dump(exclude_unset=True)
|
||||
assert "theme" in dumped
|
||||
assert "displayName" not in dumped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EncryptionMetadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEncryptionMetadata:
|
||||
def test_requires_ciphertext(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EncryptionMetadata(nonce="abc")
|
||||
|
||||
def test_requires_nonce(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EncryptionMetadata(ciphertext="abc")
|
||||
|
||||
def test_requires_both_ciphertext_and_nonce(self):
|
||||
with pytest.raises(ValidationError):
|
||||
EncryptionMetadata()
|
||||
|
||||
def test_default_algorithm_is_xsalsa20(self):
|
||||
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz")
|
||||
assert meta.algorithm == "XSalsa20-Poly1305"
|
||||
|
||||
def test_default_encrypted_is_true(self):
|
||||
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz")
|
||||
assert meta.encrypted is True
|
||||
|
||||
def test_valid_full_metadata(self):
|
||||
meta = EncryptionMetadata(
|
||||
encrypted=True,
|
||||
ciphertext="dGVzdA==",
|
||||
nonce="bm9uY2U=",
|
||||
algorithm="XSalsa20-Poly1305",
|
||||
)
|
||||
assert meta.ciphertext == "dGVzdA=="
|
||||
assert meta.nonce == "bm9uY2U="
|
||||
|
||||
def test_custom_algorithm_accepted(self):
|
||||
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz", algorithm="AES-256-GCM")
|
||||
assert meta.algorithm == "AES-256-GCM"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JournalEntryCreate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJournalEntryCreate:
|
||||
def test_all_fields_optional(self):
|
||||
entry = JournalEntryCreate()
|
||||
assert entry.title is None
|
||||
assert entry.content is None
|
||||
assert entry.encryption is None
|
||||
assert entry.mood is None
|
||||
|
||||
def test_encrypted_entry_has_no_plaintext(self):
|
||||
"""Encrypted entries legitimately have no title or content."""
|
||||
entry = JournalEntryCreate(
|
||||
encryption=EncryptionMetadata(ciphertext="abc", nonce="xyz")
|
||||
)
|
||||
assert entry.title is None
|
||||
assert entry.content is None
|
||||
assert entry.encryption is not None
|
||||
|
||||
def test_valid_mood_values(self):
|
||||
for mood in ("happy", "sad", "neutral", "anxious", "grateful"):
|
||||
entry = JournalEntryCreate(mood=mood)
|
||||
assert entry.mood == mood
|
||||
|
||||
def test_invalid_mood_raises_validation_error(self):
|
||||
with pytest.raises(ValidationError):
|
||||
JournalEntryCreate(mood="ecstatic")
|
||||
|
||||
def test_default_is_public_is_false(self):
|
||||
entry = JournalEntryCreate()
|
||||
assert entry.isPublic is False
|
||||
|
||||
def test_tags_default_is_none(self):
|
||||
entry = JournalEntryCreate()
|
||||
assert entry.tags is None
|
||||
|
||||
def test_tags_list_accepted(self):
|
||||
entry = JournalEntryCreate(tags=["family", "work", "health"])
|
||||
assert entry.tags == ["family", "work", "health"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JournalEntryUpdate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJournalEntryUpdate:
|
||||
def test_all_fields_optional(self):
|
||||
update = JournalEntryUpdate()
|
||||
assert update.title is None
|
||||
assert update.mood is None
|
||||
|
||||
def test_update_mood_only(self):
|
||||
update = JournalEntryUpdate(mood="happy")
|
||||
dumped = update.model_dump(exclude_unset=True)
|
||||
assert dumped == {"mood": MoodEnum.happy}
|
||||
|
||||
def test_invalid_mood_raises_error(self):
|
||||
with pytest.raises(ValidationError):
|
||||
JournalEntryUpdate(mood="angry")
|
||||
|
||||
def test_update_encryption(self):
|
||||
update = JournalEntryUpdate(
|
||||
encryption=EncryptionMetadata(ciphertext="new_ct", nonce="new_nonce")
|
||||
)
|
||||
assert update.encryption.ciphertext == "new_ct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MoodEnum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMoodEnum:
|
||||
def test_all_enum_values(self):
|
||||
assert MoodEnum.happy == "happy"
|
||||
assert MoodEnum.sad == "sad"
|
||||
assert MoodEnum.neutral == "neutral"
|
||||
assert MoodEnum.anxious == "anxious"
|
||||
assert MoodEnum.grateful == "grateful"
|
||||
|
||||
def test_enum_used_in_entry_create(self):
|
||||
entry = JournalEntryCreate(mood=MoodEnum.grateful)
|
||||
assert entry.mood == "grateful"
|
||||
236
backend/tests/test_users.py
Normal file
236
backend/tests/test_users.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Tests for user management endpoints (/api/users/*)."""
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def registered_user(client):
|
||||
"""Register a test user and return the API response data."""
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={
|
||||
"email": "test@example.com",
|
||||
"displayName": "Test User",
|
||||
"photoURL": "https://example.com/photo.jpg",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/users/register
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegisterUser:
|
||||
def test_register_new_user_returns_200(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "new@example.com", "displayName": "New User"})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_register_returns_user_fields(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "new@example.com", "displayName": "New User"})
|
||||
data = response.json()
|
||||
assert data["email"] == "new@example.com"
|
||||
assert data["displayName"] == "New User"
|
||||
assert "id" in data
|
||||
assert "createdAt" in data
|
||||
assert "updatedAt" in data
|
||||
|
||||
def test_register_returns_registered_message(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "brand_new@example.com"})
|
||||
assert response.json()["message"] == "User registered successfully"
|
||||
|
||||
def test_register_existing_user_is_idempotent(self, client):
|
||||
c, _ = client
|
||||
payload = {"email": "existing@example.com"}
|
||||
c.post("/api/users/register", json=payload)
|
||||
response = c.post("/api/users/register", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "User already exists"
|
||||
|
||||
def test_register_idempotent_returns_same_id(self, client):
|
||||
c, _ = client
|
||||
payload = {"email": "same@example.com"}
|
||||
r1 = c.post("/api/users/register", json=payload).json()
|
||||
r2 = c.post("/api/users/register", json=payload).json()
|
||||
assert r1["id"] == r2["id"]
|
||||
|
||||
def test_register_uses_email_prefix_as_default_display_name(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "johndoe@example.com"})
|
||||
assert response.json()["displayName"] == "johndoe"
|
||||
|
||||
def test_register_default_theme_is_light(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "x@example.com"})
|
||||
assert response.json()["theme"] == "light"
|
||||
|
||||
def test_register_missing_email_returns_422(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"displayName": "No Email"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_register_without_optional_fields(self, client):
|
||||
c, _ = client
|
||||
response = c.post("/api/users/register", json={"email": "minimal@example.com"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["photoURL"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/users/by-email/{email}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetUserByEmail:
|
||||
def test_returns_existing_user(self, client, registered_user):
|
||||
c, _ = client
|
||||
email = registered_user["email"]
|
||||
response = c.get(f"/api/users/by-email/{email}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == email
|
||||
|
||||
def test_returns_all_user_fields(self, client, registered_user):
|
||||
c, _ = client
|
||||
response = c.get(f"/api/users/by-email/{registered_user['email']}")
|
||||
data = response.json()
|
||||
for field in ("id", "email", "displayName", "theme", "createdAt", "updatedAt"):
|
||||
assert field in data
|
||||
|
||||
def test_nonexistent_email_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/users/by-email/ghost@example.com")
|
||||
assert response.status_code == 404
|
||||
assert "User not found" in response.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/users/{user_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetUserById:
|
||||
def test_returns_existing_user(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
response = c.get(f"/api/users/{user_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == user_id
|
||||
|
||||
def test_invalid_object_id_format_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/users/not-a-valid-objectid")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_nonexistent_valid_id_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.get("/api/users/507f1f77bcf86cd799439011")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/users/{user_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateUser:
|
||||
def test_update_display_name(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
response = c.put(f"/api/users/{user_id}", json={"displayName": "Updated Name"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["displayName"] == "Updated Name"
|
||||
|
||||
def test_update_theme_to_dark(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
response = c.put(f"/api/users/{user_id}", json={"theme": "dark"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["theme"] == "dark"
|
||||
|
||||
def test_update_photo_url(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
new_url = "https://new-photo.example.com/pic.jpg"
|
||||
response = c.put(f"/api/users/{user_id}", json={"photoURL": new_url})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["photoURL"] == new_url
|
||||
|
||||
def test_update_persists_to_database(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
c.put(f"/api/users/{user_id}", json={"displayName": "Persisted Name"})
|
||||
response = c.get(f"/api/users/{user_id}")
|
||||
assert response.json()["displayName"] == "Persisted Name"
|
||||
|
||||
def test_partial_update_does_not_clear_other_fields(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
# Update only theme
|
||||
c.put(f"/api/users/{user_id}", json={"theme": "dark"})
|
||||
response = c.get(f"/api/users/{user_id}")
|
||||
data = response.json()
|
||||
assert data["theme"] == "dark"
|
||||
assert data["displayName"] == "Test User" # original value preserved
|
||||
|
||||
def test_update_nonexistent_user_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.put("/api/users/507f1f77bcf86cd799439011", json={"displayName": "X"})
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_invalid_id_format_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.put("/api/users/bad-id", json={"displayName": "X"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/users/{user_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeleteUser:
|
||||
def test_delete_user_returns_200(self, client, registered_user):
|
||||
c, _ = client
|
||||
response = c.delete(f"/api/users/{registered_user['id']}")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_delete_user_returns_deletion_counts(self, client, registered_user):
|
||||
c, _ = client
|
||||
response = c.delete(f"/api/users/{registered_user['id']}")
|
||||
data = response.json()
|
||||
assert data["user_deleted"] == 1
|
||||
assert "entries_deleted" in data
|
||||
|
||||
def test_delete_user_makes_them_unretrievable(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
c.delete(f"/api/users/{user_id}")
|
||||
response = c.get(f"/api/users/{user_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_user_also_deletes_their_entries(self, client, registered_user):
|
||||
c, _ = client
|
||||
user_id = registered_user["id"]
|
||||
# Create 2 entries for this user
|
||||
for _ in range(2):
|
||||
c.post(f"/api/entries/{user_id}", json={
|
||||
"encryption": {
|
||||
"encrypted": True,
|
||||
"ciphertext": "dGVzdA==",
|
||||
"nonce": "bm9uY2U=",
|
||||
"algorithm": "XSalsa20-Poly1305",
|
||||
}
|
||||
})
|
||||
response = c.delete(f"/api/users/{user_id}")
|
||||
assert response.json()["entries_deleted"] == 2
|
||||
|
||||
def test_delete_nonexistent_user_returns_404(self, client):
|
||||
c, _ = client
|
||||
response = c.delete("/api/users/507f1f77bcf86cd799439011")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_invalid_id_format_returns_400(self, client):
|
||||
c, _ = client
|
||||
response = c.delete("/api/users/bad-id")
|
||||
assert response.status_code == 400
|
||||
89
backend/tests/test_utils.py
Normal file
89
backend/tests/test_utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for utility functions (backend/utils.py)."""
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from utils import utc_to_ist, format_ist_timestamp
|
||||
|
||||
IST = timezone(timedelta(hours=5, minutes=30))
|
||||
|
||||
|
||||
class TestUtcToIst:
|
||||
def test_midnight_utc_becomes_530_ist(self):
|
||||
utc = datetime(2024, 1, 1, 0, 0, 0)
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.hour == 5
|
||||
assert ist.minute == 30
|
||||
|
||||
def test_adds_five_hours_thirty_minutes(self):
|
||||
utc = datetime(2024, 6, 15, 10, 0, 0)
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.hour == 15
|
||||
assert ist.minute == 30
|
||||
|
||||
def test_rolls_over_to_next_day(self):
|
||||
utc = datetime(2024, 1, 1, 22, 0, 0) # 22:00 UTC → 03:30 next day IST
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.day == 2
|
||||
assert ist.hour == 3
|
||||
assert ist.minute == 30
|
||||
|
||||
def test_rolls_over_to_next_month(self):
|
||||
utc = datetime(2024, 1, 31, 23, 0, 0) # Jan 31 → Feb 1 IST
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.month == 2
|
||||
assert ist.day == 1
|
||||
|
||||
def test_output_has_ist_timezone_offset(self):
|
||||
utc = datetime(2024, 1, 1, 12, 0, 0)
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.utcoffset() == timedelta(hours=5, minutes=30)
|
||||
|
||||
def test_preserves_seconds(self):
|
||||
utc = datetime(2024, 3, 15, 8, 45, 30)
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.second == 30
|
||||
|
||||
def test_noon_utc_is_1730_ist(self):
|
||||
utc = datetime(2024, 7, 4, 12, 0, 0)
|
||||
ist = utc_to_ist(utc)
|
||||
assert ist.hour == 17
|
||||
assert ist.minute == 30
|
||||
|
||||
|
||||
class TestFormatIstTimestamp:
|
||||
def test_converts_z_suffix_timestamp(self):
|
||||
result = format_ist_timestamp("2024-01-01T00:00:00Z")
|
||||
assert "+05:30" in result
|
||||
|
||||
def test_converts_explicit_utc_offset_timestamp(self):
|
||||
result = format_ist_timestamp("2024-01-01T00:00:00+00:00")
|
||||
assert "+05:30" in result
|
||||
|
||||
def test_midnight_utc_produces_0530_ist(self):
|
||||
result = format_ist_timestamp("2024-01-01T00:00:00Z")
|
||||
assert "05:30:00+05:30" in result
|
||||
|
||||
def test_noon_utc_produces_1730_ist(self):
|
||||
result = format_ist_timestamp("2024-01-01T12:00:00Z")
|
||||
assert "17:30:00+05:30" in result
|
||||
|
||||
def test_returns_iso_format_string(self):
|
||||
result = format_ist_timestamp("2024-06-15T08:00:00Z")
|
||||
# Should be parseable as ISO datetime
|
||||
parsed = datetime.fromisoformat(result)
|
||||
assert parsed is not None
|
||||
|
||||
def test_invalid_text_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
format_ist_timestamp("not-a-date")
|
||||
|
||||
def test_invalid_month_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
format_ist_timestamp("2024-13-01T00:00:00Z")
|
||||
|
||||
def test_empty_string_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
format_ist_timestamp("")
|
||||
|
||||
def test_slash_separated_date_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
format_ist_timestamp("2024/01/01T00:00:00") # Slashes not valid ISO format
|
||||
1285
package-lock.json
generated
1285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"driver.js": "^1.4.0",
|
||||
@@ -19,16 +22,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^17.4.4",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
308
src/__tests__/api.test.ts
Normal file
308
src/__tests__/api.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for the API service layer (src/lib/api.ts)
|
||||
*
|
||||
* All HTTP calls are intercepted by mocking global.fetch.
|
||||
* Tests verify correct URL construction, headers, methods, and error handling.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
registerUser,
|
||||
getUserByEmail,
|
||||
updateUserProfile,
|
||||
deleteUser,
|
||||
createEntry,
|
||||
getUserEntries,
|
||||
getEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
convertUTCToIST,
|
||||
} from '../lib/api'
|
||||
|
||||
const TOKEN = 'firebase-id-token'
|
||||
const USER_ID = '507f1f77bcf86cd799439011'
|
||||
const ENTRY_ID = '507f1f77bcf86cd799439022'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchError(detail: string, status: number) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
statusText: 'Error',
|
||||
json: () => Promise.resolve({ detail }),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchNetworkError() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('registerUser', () => {
|
||||
it('sends POST to /users/register', async () => {
|
||||
mockFetch({ id: USER_ID, email: 'a@b.com', message: 'User registered successfully' })
|
||||
await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/users/register'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('includes Authorization Bearer token in headers', async () => {
|
||||
mockFetch({})
|
||||
await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sends displayName and photoURL in body', async () => {
|
||||
mockFetch({})
|
||||
await registerUser({ email: 'a@b.com', displayName: 'Alice', photoURL: 'https://pic.url' }, TOKEN)
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ email: 'a@b.com', displayName: 'Alice' })
|
||||
})
|
||||
|
||||
it('returns the parsed response', async () => {
|
||||
const response = { id: USER_ID, email: 'a@b.com', message: 'User registered successfully' }
|
||||
mockFetch(response)
|
||||
const result = await registerUser({ email: 'a@b.com' }, TOKEN)
|
||||
expect(result).toEqual(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserByEmail', () => {
|
||||
it('sends GET to /users/by-email/{email}', async () => {
|
||||
mockFetch({ id: USER_ID, email: 'test@example.com' })
|
||||
await getUserByEmail('test@example.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/users/by-email/test@example.com'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws "User not found" on 404', async () => {
|
||||
mockFetchError('User not found', 404)
|
||||
await expect(getUserByEmail('ghost@example.com', TOKEN)).rejects.toThrow('User not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
it('sends PUT to /users/{userId}', async () => {
|
||||
mockFetch({ id: USER_ID, theme: 'dark', message: 'User updated successfully' })
|
||||
await updateUserProfile(USER_ID, { theme: 'dark' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/users/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends only the provided fields', async () => {
|
||||
mockFetch({})
|
||||
await updateUserProfile(USER_ID, { displayName: 'New Name' }, TOKEN)
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
|
||||
expect(body).toMatchObject({ displayName: 'New Name' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('sends DELETE to /users/{userId}', async () => {
|
||||
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 3 })
|
||||
await deleteUser(USER_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/users/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns deletion counts', async () => {
|
||||
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 5 })
|
||||
const result = await deleteUser(USER_ID, TOKEN)
|
||||
expect(result).toMatchObject({ user_deleted: 1, entries_deleted: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createEntry', () => {
|
||||
const encryptedEntry = {
|
||||
encryption: {
|
||||
encrypted: true,
|
||||
ciphertext: 'dGVzdA==',
|
||||
nonce: 'bm9uY2U=',
|
||||
algorithm: 'XSalsa20-Poly1305',
|
||||
},
|
||||
}
|
||||
|
||||
it('sends POST to /entries/{userId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
|
||||
await createEntry(USER_ID, encryptedEntry, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}`),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns entry id and message', async () => {
|
||||
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
|
||||
const result = await createEntry(USER_ID, encryptedEntry, TOKEN)
|
||||
expect(result).toMatchObject({ id: ENTRY_ID })
|
||||
})
|
||||
|
||||
it('throws on 404 when user not found', async () => {
|
||||
mockFetchError('User not found', 404)
|
||||
await expect(createEntry('nonexistent-user', encryptedEntry, TOKEN)).rejects.toThrow('User not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserEntries', () => {
|
||||
it('sends GET to /entries/{userId} with default pagination', async () => {
|
||||
mockFetch({ entries: [], total: 0 })
|
||||
await getUserEntries(USER_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}?limit=50&skip=0`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('respects custom limit and skip', async () => {
|
||||
mockFetch({ entries: [], total: 0 })
|
||||
await getUserEntries(USER_ID, TOKEN, 10, 20)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('limit=10&skip=20'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns entries and total', async () => {
|
||||
mockFetch({ entries: [{ id: ENTRY_ID }], total: 1 })
|
||||
const result = await getUserEntries(USER_ID, TOKEN)
|
||||
expect(result).toMatchObject({ total: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntry', () => {
|
||||
it('sends GET to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID, userId: USER_ID, createdAt: '2024-01-01', updatedAt: '2024-01-01' })
|
||||
await getEntry(USER_ID, ENTRY_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('throws "Entry not found" on 404', async () => {
|
||||
mockFetchError('Entry not found', 404)
|
||||
await expect(getEntry(USER_ID, 'bad-id', TOKEN)).rejects.toThrow('Entry not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateEntry', () => {
|
||||
it('sends PUT to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ id: ENTRY_ID })
|
||||
await updateEntry(USER_ID, ENTRY_ID, { mood: 'happy' }, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteEntry', () => {
|
||||
it('sends DELETE to /entries/{userId}/{entryId}', async () => {
|
||||
mockFetch({ message: 'Entry deleted successfully' })
|
||||
await deleteEntry(USER_ID, ENTRY_ID, TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertUTCToIST', () => {
|
||||
it('sends POST to /entries/convert-timestamp/utc-to-ist', async () => {
|
||||
const utc = '2024-01-01T00:00:00Z'
|
||||
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
|
||||
await convertUTCToIST(utc)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/convert-timestamp/utc-to-ist'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns both utc and ist fields', async () => {
|
||||
const utc = '2024-01-01T00:00:00Z'
|
||||
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
|
||||
const result = await convertUTCToIST(utc)
|
||||
expect(result).toMatchObject({ utc, ist: expect.stringContaining('+05:30') })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic Error Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API error handling', () => {
|
||||
it('throws the error detail from response body', async () => {
|
||||
mockFetchError('Specific backend error message', 400)
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Specific backend error message')
|
||||
})
|
||||
|
||||
it('falls back to "API error: {statusText}" when body has no detail', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('no JSON')),
|
||||
}))
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('API error: Internal Server Error')
|
||||
})
|
||||
|
||||
it('propagates network errors', async () => {
|
||||
mockFetchNetworkError()
|
||||
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('includes credentials: include in all requests', async () => {
|
||||
mockFetch({})
|
||||
await getUserByEmail('x@x.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ credentials: 'include' })
|
||||
)
|
||||
})
|
||||
|
||||
it('sets Content-Type: application/json on all requests', async () => {
|
||||
mockFetch({})
|
||||
await getUserByEmail('x@x.com', TOKEN)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
284
src/__tests__/crypto.test.ts
Normal file
284
src/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Tests for client-side encryption utilities (src/lib/crypto.ts)
|
||||
*
|
||||
* Uses a self-consistent XOR-based sodium mock so tests run without
|
||||
* WebAssembly (libsodium) in the Node/happy-dom environment.
|
||||
* The real PBKDF2 key derivation (Web Crypto API) is tested as-is.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
deriveSecretKey,
|
||||
generateDeviceKey,
|
||||
encryptEntry,
|
||||
decryptEntry,
|
||||
encryptSecretKey,
|
||||
decryptSecretKey,
|
||||
generateSalt,
|
||||
getSalt,
|
||||
saveSalt,
|
||||
saveDeviceKey,
|
||||
getDeviceKey,
|
||||
clearDeviceKey,
|
||||
} from '../lib/crypto'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-consistent sodium mock (XOR cipher + 16-byte auth tag)
|
||||
// encrypt(msg, key) = tag(16 zeros) || xor(msg, key)
|
||||
// decrypt(ct, key) = xor(ct[16:], key)
|
||||
// Wrong-key behavior is tested by overriding crypto_secretbox_open_easy to throw.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||
return data.map((byte, i) => byte ^ key[i % key.length])
|
||||
}
|
||||
|
||||
const createMockSodium = (overrides: Record<string, unknown> = {}) => ({
|
||||
randombytes_buf: (size: number) => new Uint8Array(size).fill(42),
|
||||
crypto_secretbox_NONCEBYTES: 24,
|
||||
crypto_secretbox_easy: (msg: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
|
||||
const tag = new Uint8Array(16)
|
||||
const encrypted = xorBytes(msg, key)
|
||||
const result = new Uint8Array(tag.length + encrypted.length)
|
||||
result.set(tag)
|
||||
result.set(encrypted, tag.length)
|
||||
return result
|
||||
},
|
||||
crypto_secretbox_open_easy: (ct: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
|
||||
if (ct.length < 16) throw new Error('invalid ciphertext length')
|
||||
return xorBytes(ct.slice(16), key)
|
||||
},
|
||||
to_base64: (data: Uint8Array) => Buffer.from(data).toString('base64'),
|
||||
from_base64: (str: string) => new Uint8Array(Buffer.from(str, 'base64')),
|
||||
from_string: (str: string) => new TextEncoder().encode(str),
|
||||
to_string: (data: Uint8Array) => new TextDecoder().decode(data),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('../utils/sodium', () => ({
|
||||
getSodium: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('crypto utilities', () => {
|
||||
beforeEach(async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValue(createMockSodium() as never)
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// ── deriveSecretKey ──────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveSecretKey', () => {
|
||||
it('returns a 32-byte Uint8Array', async () => {
|
||||
const key = await deriveSecretKey('test-uid-123', 'test-salt')
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
|
||||
it('is deterministic — same inputs always produce the same key', async () => {
|
||||
const key1 = await deriveSecretKey('uid-abc', 'salt-xyz')
|
||||
const key2 = await deriveSecretKey('uid-abc', 'salt-xyz')
|
||||
expect(key1).toEqual(key2)
|
||||
})
|
||||
|
||||
it('different UIDs produce different keys', async () => {
|
||||
const key1 = await deriveSecretKey('uid-1', 'same-salt')
|
||||
const key2 = await deriveSecretKey('uid-2', 'same-salt')
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
|
||||
it('different salts produce different keys', async () => {
|
||||
const key1 = await deriveSecretKey('same-uid', 'salt-a')
|
||||
const key2 = await deriveSecretKey('same-uid', 'salt-b')
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
|
||||
it('handles empty UID string', async () => {
|
||||
const key = await deriveSecretKey('', 'some-salt')
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
// ── generateDeviceKey ────────────────────────────────────────────────────
|
||||
|
||||
describe('generateDeviceKey', () => {
|
||||
it('returns a 32-byte Uint8Array', async () => {
|
||||
const key = await generateDeviceKey()
|
||||
expect(key).toBeInstanceOf(Uint8Array)
|
||||
expect(key.length).toBe(32)
|
||||
})
|
||||
|
||||
it('generates unique keys each time (random)', async () => {
|
||||
const key1 = await generateDeviceKey()
|
||||
const key2 = await generateDeviceKey()
|
||||
// Two random 256-bit arrays should be different
|
||||
expect(key1).not.toEqual(key2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── encryptEntry / decryptEntry ──────────────────────────────────────────
|
||||
|
||||
describe('encryptEntry / decryptEntry', () => {
|
||||
const secretKey = new Uint8Array(32).fill(1)
|
||||
|
||||
it('roundtrip: decrypting an encrypted entry returns original content', async () => {
|
||||
const content = 'Today I am grateful for my family.'
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('returns base64-encoded strings for ciphertext and nonce', async () => {
|
||||
const { ciphertext, nonce } = await encryptEntry('test content', secretKey)
|
||||
expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow()
|
||||
expect(() => Buffer.from(nonce, 'base64')).not.toThrow()
|
||||
// Valid base64 only contains these characters
|
||||
expect(ciphertext).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
it('handles empty string content', async () => {
|
||||
const { ciphertext, nonce } = await encryptEntry('', secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe('')
|
||||
})
|
||||
|
||||
it('handles unicode and emoji content', async () => {
|
||||
const content = 'Grateful for 🌟 life! नमस्ते 日本語'
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('handles very long content (10,000 chars)', async () => {
|
||||
const content = 'a'.repeat(10000)
|
||||
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
|
||||
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
|
||||
expect(decrypted).toBe(content)
|
||||
})
|
||||
|
||||
it('different plaintext produces different ciphertext', async () => {
|
||||
const { ciphertext: ct1 } = await encryptEntry('hello world', secretKey)
|
||||
const { ciphertext: ct2 } = await encryptEntry('goodbye world', secretKey)
|
||||
expect(ct1).not.toBe(ct2)
|
||||
})
|
||||
|
||||
it('decryptEntry throws "Failed to decrypt entry" on bad ciphertext', async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
|
||||
crypto_secretbox_open_easy: () => { throw new Error('invalid mac') },
|
||||
}) as never)
|
||||
await expect(decryptEntry('notvalidbase64!!', 'nonce', secretKey))
|
||||
.rejects.toThrow('Failed to decrypt entry')
|
||||
})
|
||||
|
||||
it('decryptEntry throws when called with wrong key', async () => {
|
||||
// Simulate libsodium authentication failure with wrong key
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium)
|
||||
.mockResolvedValueOnce(createMockSodium() as never) // for encrypt
|
||||
.mockResolvedValueOnce(createMockSodium({ // for decrypt (wrong key throws)
|
||||
crypto_secretbox_open_easy: () => { throw new Error('incorrect key') },
|
||||
}) as never)
|
||||
|
||||
const { ciphertext, nonce } = await encryptEntry('secret', secretKey)
|
||||
const wrongKey = new Uint8Array(32).fill(99)
|
||||
await expect(decryptEntry(ciphertext, nonce, wrongKey))
|
||||
.rejects.toThrow('Failed to decrypt entry')
|
||||
})
|
||||
})
|
||||
|
||||
// ── encryptSecretKey / decryptSecretKey ──────────────────────────────────
|
||||
|
||||
describe('encryptSecretKey / decryptSecretKey', () => {
|
||||
it('roundtrip: encrypts and decrypts master key back to original', async () => {
|
||||
const masterKey = new Uint8Array(32).fill(99)
|
||||
const deviceKey = new Uint8Array(32).fill(55)
|
||||
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
|
||||
const decrypted = await decryptSecretKey(ciphertext, nonce, deviceKey)
|
||||
expect(decrypted).toEqual(masterKey)
|
||||
})
|
||||
|
||||
it('returns base64 strings', async () => {
|
||||
const masterKey = new Uint8Array(32).fill(1)
|
||||
const deviceKey = new Uint8Array(32).fill(2)
|
||||
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
|
||||
expect(typeof ciphertext).toBe('string')
|
||||
expect(typeof nonce).toBe('string')
|
||||
})
|
||||
|
||||
it('decryptSecretKey throws "Failed to decrypt secret key" on wrong device key', async () => {
|
||||
const { getSodium } = await import('../utils/sodium')
|
||||
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
|
||||
crypto_secretbox_open_easy: () => { throw new Error('decryption failed') },
|
||||
}) as never)
|
||||
await expect(decryptSecretKey('fakeciphertext', 'fakenonce', new Uint8Array(32)))
|
||||
.rejects.toThrow('Failed to decrypt secret key')
|
||||
})
|
||||
})
|
||||
|
||||
// ── salt functions ───────────────────────────────────────────────────────
|
||||
|
||||
describe('generateSalt / saveSalt / getSalt', () => {
|
||||
it('generateSalt returns the constant salt string', () => {
|
||||
expect(generateSalt()).toBe('grateful-journal-v1')
|
||||
})
|
||||
|
||||
it('generateSalt is idempotent', () => {
|
||||
expect(generateSalt()).toBe(generateSalt())
|
||||
})
|
||||
|
||||
it('saveSalt and getSalt roundtrip', () => {
|
||||
saveSalt('my-custom-salt')
|
||||
expect(getSalt()).toBe('my-custom-salt')
|
||||
})
|
||||
|
||||
it('getSalt returns null when nothing stored', () => {
|
||||
localStorage.clear()
|
||||
expect(getSalt()).toBeNull()
|
||||
})
|
||||
|
||||
it('overwriting salt replaces old value', () => {
|
||||
saveSalt('first')
|
||||
saveSalt('second')
|
||||
expect(getSalt()).toBe('second')
|
||||
})
|
||||
})
|
||||
|
||||
// ── device key localStorage ──────────────────────────────────────────────
|
||||
|
||||
describe('saveDeviceKey / getDeviceKey / clearDeviceKey', () => {
|
||||
it('saves and retrieves device key correctly', async () => {
|
||||
const key = new Uint8Array(32).fill(7)
|
||||
await saveDeviceKey(key)
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toEqual(key)
|
||||
})
|
||||
|
||||
it('returns null when no device key is stored', async () => {
|
||||
localStorage.clear()
|
||||
const key = await getDeviceKey()
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('clearDeviceKey removes the stored key', async () => {
|
||||
const key = new Uint8Array(32).fill(7)
|
||||
await saveDeviceKey(key)
|
||||
clearDeviceKey()
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toBeNull()
|
||||
})
|
||||
|
||||
it('overwriting device key stores the new key', async () => {
|
||||
const key1 = new Uint8Array(32).fill(1)
|
||||
const key2 = new Uint8Array(32).fill(2)
|
||||
await saveDeviceKey(key1)
|
||||
await saveDeviceKey(key2)
|
||||
const retrieved = await getDeviceKey()
|
||||
expect(retrieved).toEqual(key2)
|
||||
})
|
||||
})
|
||||
})
|
||||
3
src/__tests__/setup.ts
Normal file
3
src/__tests__/setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Global test setup
|
||||
// happy-dom provides: crypto (Web Crypto API), localStorage, sessionStorage, IndexedDB, fetch
|
||||
// No additional polyfills needed for this project
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**', 'src/utils/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user