iObj: initial service — FastAPI + TinyDB memory object store
All checks were successful
Build and Deploy iObj / build-image (push) Successful in 1h17m41s
All checks were successful
Build and Deploy iObj / build-image (push) Successful in 1h17m41s
- Month-sharded TinyDB storage with search/filter (type, project, tag, date range, free-text) - FastAPI app with bearer-token auth, CRUD + search endpoints - 27 passing pytest tests (storage + API) - Dockerfile + Nomad job spec + Gitea Actions deploy workflow (mirrors devops-mcp pattern)
This commit is contained in:
135
tests/test_api.py
Normal file
135
tests/test_api.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import tempfile
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import iobj.app as app_module
|
||||
from iobj.storage import ObjectStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
monkeypatch.setattr(app_module, "store", ObjectStore(tmp))
|
||||
monkeypatch.setattr(app_module, "API_TOKEN", "secret-token")
|
||||
with TestClient(app_module.app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
AUTH = {"Authorization": "Bearer secret-token"}
|
||||
|
||||
|
||||
def test_health_no_auth_required(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_create_requires_auth(client):
|
||||
resp = client.post("/objects", json={"type": "recap", "title": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_with_wrong_token_rejected(client):
|
||||
resp = client.post(
|
||||
"/objects",
|
||||
json={"type": "recap", "title": "x"},
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_and_get_object(client):
|
||||
resp = client.post(
|
||||
"/objects",
|
||||
json={
|
||||
"type": "recap",
|
||||
"object_date": "2026-07-01",
|
||||
"project": "NordBytes",
|
||||
"tags": ["frontend"],
|
||||
"title": "Test",
|
||||
"body": "body text",
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = resp.json()
|
||||
assert obj["title"] == "Test"
|
||||
obj_id = obj["id"]
|
||||
|
||||
resp = client.get(f"/objects/{obj_id}", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == obj_id
|
||||
|
||||
|
||||
def test_get_missing_object_404(client):
|
||||
resp = client.get("/objects/does-not-exist", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_search_objects(client):
|
||||
client.post(
|
||||
"/objects",
|
||||
json={"type": "git_commit", "object_date": "2026-05-01", "title": "commit a", "project": "Egmont"},
|
||||
headers=AUTH,
|
||||
)
|
||||
client.post(
|
||||
"/objects",
|
||||
json={"type": "recap", "object_date": "2026-05-01", "title": "recap a", "project": "NordBytes"},
|
||||
headers=AUTH,
|
||||
)
|
||||
resp = client.get("/objects", params={"type": "git_commit"}, headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "commit a"
|
||||
|
||||
|
||||
def test_objects_for_date(client):
|
||||
client.post(
|
||||
"/objects",
|
||||
json={"type": "standup", "object_date": "2026-05-05", "title": "standup"},
|
||||
headers=AUTH,
|
||||
)
|
||||
client.post(
|
||||
"/objects",
|
||||
json={"type": "standup", "object_date": "2026-05-06", "title": "other day"},
|
||||
headers=AUTH,
|
||||
)
|
||||
resp = client.get("/objects/date/2026-05-05", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "standup"
|
||||
|
||||
|
||||
def test_update_object(client):
|
||||
resp = client.post(
|
||||
"/objects", json={"type": "recap", "object_date": "2026-05-05", "title": "orig"}, headers=AUTH
|
||||
)
|
||||
obj_id = resp.json()["id"]
|
||||
resp = client.patch(f"/objects/{obj_id}", json={"title": "changed"}, headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "changed"
|
||||
|
||||
|
||||
def test_update_missing_object_404(client):
|
||||
resp = client.patch("/objects/nope", json={"title": "x"}, headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_object(client):
|
||||
resp = client.post(
|
||||
"/objects", json={"type": "recap", "object_date": "2026-05-05", "title": "to-delete"}, headers=AUTH
|
||||
)
|
||||
obj_id = resp.json()["id"]
|
||||
resp = client.delete(f"/objects/{obj_id}", headers=AUTH)
|
||||
assert resp.status_code == 204
|
||||
resp = client.get(f"/objects/{obj_id}", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_missing_object_404(client):
|
||||
resp = client.delete("/objects/nope", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
142
tests/test_storage.py
Normal file
142
tests/test_storage.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import tempfile
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from iobj.models import ObjectCreate, ObjectType, ObjectUpdate, SearchQuery
|
||||
from iobj.storage import ObjectStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
s = ObjectStore(tmp)
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
def _mk(**kwargs) -> ObjectCreate:
|
||||
defaults = dict(
|
||||
type=ObjectType.RECAP,
|
||||
object_date=date(2026, 7, 1),
|
||||
project="NordBytes",
|
||||
tags=["frontend"],
|
||||
title="Test title",
|
||||
body="Some body text about BDD tests",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ObjectCreate(**defaults)
|
||||
|
||||
|
||||
def test_create_and_get_roundtrip(store):
|
||||
obj = store.create(_mk())
|
||||
fetched = store.get(obj.id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == obj.id
|
||||
assert fetched.title == "Test title"
|
||||
assert fetched.project == "NordBytes"
|
||||
|
||||
|
||||
def test_get_missing_returns_none(store):
|
||||
assert store.get("does-not-exist") is None
|
||||
|
||||
|
||||
def test_shard_file_created_per_month(store):
|
||||
store.create(_mk(object_date=date(2026, 1, 15)))
|
||||
store.create(_mk(object_date=date(2026, 3, 2)))
|
||||
shard_files = sorted(p.name for p in store.data_dir.glob("*.json"))
|
||||
assert shard_files == ["2026-01.json", "2026-03.json"]
|
||||
|
||||
|
||||
def test_update_changes_fields(store):
|
||||
obj = store.create(_mk())
|
||||
updated = store.update(obj.id, ObjectUpdate(title="New title", tags=["backend"]))
|
||||
assert updated.title == "New title"
|
||||
assert updated.tags == ["backend"]
|
||||
assert updated.body == obj.body # untouched field preserved
|
||||
|
||||
|
||||
def test_update_moves_shard_when_date_changes(store):
|
||||
obj = store.create(_mk(object_date=date(2026, 1, 1)))
|
||||
store.update(obj.id, ObjectUpdate(object_date=date(2026, 6, 1)))
|
||||
shard_files = sorted(p.name for p in store.data_dir.glob("*.json"))
|
||||
assert "2026-06.json" in shard_files
|
||||
moved = store.get(obj.id)
|
||||
assert moved.object_date == date(2026, 6, 1)
|
||||
|
||||
|
||||
def test_update_missing_returns_none(store):
|
||||
assert store.update("nope", ObjectUpdate(title="x")) is None
|
||||
|
||||
|
||||
def test_delete_removes_object(store):
|
||||
obj = store.create(_mk())
|
||||
assert store.delete(obj.id) is True
|
||||
assert store.get(obj.id) is None
|
||||
|
||||
|
||||
def test_delete_missing_returns_false(store):
|
||||
assert store.delete("nope") is False
|
||||
|
||||
|
||||
def test_search_filters_by_type(store):
|
||||
store.create(_mk(type=ObjectType.RECAP))
|
||||
store.create(_mk(type=ObjectType.GIT_COMMIT, title="commit one"))
|
||||
results = store.search(SearchQuery(type=ObjectType.GIT_COMMIT))
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "commit one"
|
||||
|
||||
|
||||
def test_search_filters_by_project_case_insensitive(store):
|
||||
store.create(_mk(project="NordBytes"))
|
||||
store.create(_mk(project="Egmont"))
|
||||
results = store.search(SearchQuery(project="nordbytes"))
|
||||
assert len(results) == 1
|
||||
assert results[0].project == "NordBytes"
|
||||
|
||||
|
||||
def test_search_filters_by_tag(store):
|
||||
store.create(_mk(tags=["frontend", "bdd"]))
|
||||
store.create(_mk(tags=["backend"]))
|
||||
results = store.search(SearchQuery(tag="bdd"))
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_search_filters_by_date_range(store):
|
||||
store.create(_mk(object_date=date(2026, 1, 5), title="jan"))
|
||||
store.create(_mk(object_date=date(2026, 6, 5), title="jun"))
|
||||
store.create(_mk(object_date=date(2026, 12, 5), title="dec"))
|
||||
results = store.search(SearchQuery(date_from=date(2026, 2, 1), date_to=date(2026, 7, 1)))
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "jun"
|
||||
|
||||
|
||||
def test_search_free_text_matches_title_and_body(store):
|
||||
store.create(_mk(title="BDD patterns", body="irrelevant"))
|
||||
store.create(_mk(title="irrelevant", body="mentions BDD somewhere"))
|
||||
store.create(_mk(title="nothing", body="nothing"))
|
||||
results = store.search(SearchQuery(q="bdd"))
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
def test_search_respects_limit_and_offset_and_sort_order(store):
|
||||
for i in range(5):
|
||||
store.create(_mk(object_date=date(2026, 1, 1 + i), title=f"obj{i}"))
|
||||
all_results = store.search(SearchQuery(limit=100))
|
||||
assert [r.title for r in all_results] == ["obj4", "obj3", "obj2", "obj1", "obj0"]
|
||||
|
||||
page = store.search(SearchQuery(limit=2, offset=1))
|
||||
assert [r.title for r in page] == ["obj3", "obj2"]
|
||||
|
||||
|
||||
def test_search_combines_multiple_filters(store):
|
||||
store.create(_mk(project="NordBytes", tags=["bdd"], title="match", object_date=date(2026, 5, 5)))
|
||||
store.create(_mk(project="NordBytes", tags=["other"], title="no-tag-match", object_date=date(2026, 5, 5)))
|
||||
store.create(_mk(project="Egmont", tags=["bdd"], title="wrong-project", object_date=date(2026, 5, 5)))
|
||||
results = store.search(SearchQuery(project="NordBytes", tag="bdd"))
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "match"
|
||||
|
||||
|
||||
def test_search_no_shards_returns_empty(store):
|
||||
assert store.search(SearchQuery()) == []
|
||||
Reference in New Issue
Block a user