This commit is contained in:
2026-03-24 10:48:20 +05:30
parent bd1af0bf44
commit 6e425e2f04
21 changed files with 3021 additions and 50 deletions

View File

@@ -4,7 +4,13 @@
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(curl -s http://127.0.0.1:8000/api/users/by-email/jeet.debnath2004@gmail.com)", "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
View File

@@ -15,6 +15,20 @@ dist-ssr
.env.* .env.*
.env.local .env.local
# Test coverage reports
coverage/
.coverage
htmlcov/
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Claude Code memory (local only)
memory/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests

View File

@@ -1,7 +1,12 @@
fastapi==0.104.1 fastapi>=0.115.0
uvicorn==0.24.0 uvicorn==0.24.0
pymongo==4.6.0 pymongo==4.6.0
pydantic==2.5.0 pydantic>=2.5.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pydantic-settings==2.1.0 pydantic-settings>=2.1.0
python-multipart==0.0.6 python-multipart==0.0.6
# Testing
pytest>=7.4.0
httpx>=0.25.0
mongomock>=4.1.2

View File

@@ -5,6 +5,7 @@ from models import JournalEntryCreate, JournalEntryUpdate, JournalEntry, Entries
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId
from utils import format_ist_timestamp from utils import format_ist_timestamp
router = APIRouter() router = APIRouter()
@@ -50,7 +51,10 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Verify user exists # Verify user exists
user = db.users.find_one({"_id": user_oid}) user = db.users.find_one({"_id": user_oid})
if not user: if not user:
@@ -91,9 +95,6 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to create entry: {str(e)}") status_code=500, detail=f"Failed to create entry: {str(e)}")
@@ -113,7 +114,10 @@ async def get_user_entries(
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Verify user exists # Verify user exists
user = db.users.find_one({"_id": user_oid}) user = db.users.find_one({"_id": user_oid})
if not user: if not user:
@@ -142,10 +146,9 @@ async def get_user_entries(
"hasMore": has_more "hasMore": has_more
} }
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") 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: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_id) entry_oid = ObjectId(entry_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid ID format")
try:
entry = db.entries.find_one({ entry = db.entries.find_one({
"_id": entry_oid, "_id": entry_oid,
"userId": user_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") raise HTTPException(status_code=404, detail="Entry not found")
return _format_entry(entry) return _format_entry(entry)
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entry: {str(e)}") 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: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_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 = entry_data.model_dump(exclude_unset=True)
update_data["updatedAt"] = datetime.utcnow() 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 # Fetch and return updated entry
entry = db.entries.find_one({"_id": entry_oid}) entry = db.entries.find_one({"_id": entry_oid})
return _format_entry(entry) return _format_entry(entry)
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to update entry: {str(e)}") 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: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_id) entry_oid = ObjectId(entry_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid ID format")
try:
result = db.entries.delete_one({ result = db.entries.delete_one({
"_id": entry_oid, "_id": entry_oid,
"userId": user_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") raise HTTPException(status_code=404, detail="Entry not found")
return {"message": "Entry deleted successfully"} return {"message": "Entry deleted successfully"}
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to delete entry: {str(e)}") 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: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Parse date # Parse date
target_date = datetime.strptime(date_str, "%Y-%m-%d") target_date = datetime.strptime(date_str, "%Y-%m-%d")
next_date = target_date + timedelta(days=1) 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: except ValueError:
raise HTTPException( raise HTTPException(
status_code=400, detail="Invalid date format. Use YYYY-MM-DD") status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") 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: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
if not (1 <= month <= 12): if not (1 <= month <= 12):
raise HTTPException( raise HTTPException(
status_code=400, detail="Month must be between 1 and 12") 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: except ValueError:
raise HTTPException(status_code=400, detail="Invalid year or month") raise HTTPException(status_code=400, detail="Invalid year or month")
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") 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, "utc": utc_timestamp,
"ist": ist_timestamp "ist": ist_timestamp
} }
except HTTPException:
raise
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -1,11 +1,11 @@
"""User management routes""" """User management routes"""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError, WriteError
from db import get_database from db import get_database
from models import UserCreate, UserUpdate, User from models import UserCreate, UserUpdate, User
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId
router = APIRouter() router = APIRouter()
@@ -56,6 +56,8 @@ async def register_user(user_data: UserCreate):
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),
"message": "User registered successfully" if result.upserted_id else "User already exists" "message": "User registered successfully" if result.upserted_id else "User already exists"
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Registration failed: {str(e)}") 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(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch user: {str(e)}") 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() db = get_database()
try: 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: if not user:
raise HTTPException(status_code=404, detail="User not found") 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(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch user: {str(e)}") 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.""" """Update user profile."""
db = get_database() db = get_database()
try:
user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try: try:
# Prepare update data (exclude None values) # Prepare update data (exclude None values)
update_data = user_data.model_dump(exclude_unset=True) update_data = user_data.model_dump(exclude_unset=True)
update_data["updatedAt"] = datetime.utcnow() update_data["updatedAt"] = datetime.utcnow()
result = db.users.update_one( result = db.users.update_one(
{"_id": ObjectId(user_id)}, {"_id": user_oid},
{"$set": update_data} {"$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") raise HTTPException(status_code=404, detail="User not found")
# Fetch and return updated user # Fetch and return updated user
user = db.users.find_one({"_id": ObjectId(user_id)}) user = db.users.find_one({"_id": user_oid})
return { return {
"id": str(user["_id"]), "id": str(user["_id"]),
"email": user["email"], "email": user["email"],
@@ -142,10 +155,9 @@ async def update_user(user_id: str, user_data: UserUpdate):
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),
"message": "User updated successfully" "message": "User updated successfully"
} }
except HTTPException:
raise
except Exception as e: 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)}") 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.""" """Delete user account and all associated data."""
db = get_database() db = get_database()
try:
user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try: try:
# Delete user # 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: if user_result.deleted_count == 0:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Delete all user's entries # 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 { return {
"message": "User deleted successfully", "message": "User deleted successfully",
"user_deleted": user_result.deleted_count, "user_deleted": user_result.deleted_count,
"entries_deleted": entry_result.deleted_count "entries_deleted": entry_result.deleted_count
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Deletion failed: {str(e)}") 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))

View File

41
backend/tests/conftest.py Normal file
View 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

View 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

View 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
View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
@@ -19,16 +22,22 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@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/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^3.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^17.4.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "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
View 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' }),
})
)
})
})

View 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
View 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
View 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/**'],
},
},
})