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

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

View File

@@ -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:

View File

@@ -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))

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