Initial Commit

slip slop
This commit is contained in:
Ian Keane 2026-06-09 15:07:08 -04:00
commit 064e672ab7
61 changed files with 3504 additions and 0 deletions

53
tests/conftest.py Normal file
View file

@ -0,0 +1,53 @@
"""
Shared pytest fixtures.
Tests run against SQLite in-memory no Docker required.
"""
import os
import pytest
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("API_KEY", "test-key")
from app import create_app
from app.extensions import db as _db
@pytest.fixture(scope="session")
def app():
application = create_app()
application.config.update(
TESTING=True,
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
)
with application.app_context():
_db.create_all()
# Seed instruments
from app.models import Instrument
for name in ("banjo", "fiddle"):
if not _db.session.query(Instrument).filter_by(name=name).first():
_db.session.add(Instrument(name=name))
_db.session.commit()
yield application
_db.drop_all()
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture(autouse=True)
def clean_db(app):
with app.app_context():
yield
_db.session.rollback()
from app.models import (TuneByInstrumentNote, TuneByInstrument,
Reference, TuneNote, Tune, Source, Musician, Tuning)
for model in (TuneByInstrumentNote, Reference, TuneNote,
TuneByInstrument, Tune, Source, Musician, Tuning):
_db.session.query(model).delete()
_db.session.commit()
AUTH = {"X-API-Key": "test-key"}
BAD_AUTH = {"X-API-Key": "wrong-key"}

130
tests/test_instruments.py Normal file
View file

@ -0,0 +1,130 @@
"""Tests for tune_by_instrument endpoints."""
from tests.conftest import AUTH
def make_tune(client):
r = client.post("/tunes/", json={"name": "Test Tune", "key": "D"}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
def make_musician(client, name="Earl Scruggs"):
r = client.post("/musicians/", json={"name": name}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
def get_instrument_id(client, name):
return next(i["id"] for i in client.get("/instruments").get_json() if i["name"] == name)
def make_tuning(client, name="double C"):
r = client.post("/tunings/", json={"name": name}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
class TestTuneByInstrument:
def test_create_entry(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
tuning = make_tuning(client, "double C")
r = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id, "tuning_id": tuning["id"], "callable": True},
headers=AUTH)
assert r.status_code == 201
body = r.get_json()
assert body["instrument_name"] == "banjo"
assert body["tuning"] == "double C"
assert body["callable"] is True
def test_create_with_musician(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
m = make_musician(client)
r = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id, "learned_from_id": m["id"]},
headers=AUTH)
assert r.status_code == 201
assert r.get_json()["learned_from"] == "Earl Scruggs"
def test_update_entry(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
tuning = make_tuning(client, "sawmill")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id, "callable": False},
headers=AUTH).get_json()
r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}',
json={"callable": True, "tuning_id": tuning["id"]}, headers=AUTH)
assert r.status_code == 200
assert r.get_json()["callable"] is True
assert r.get_json()["tuning"] == "sawmill"
def test_delete_entry(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}', headers=AUTH)
assert r.status_code == 200
remaining = client.get(f'/tunes/{tune["id"]}/instruments').get_json()
assert all(e["id"] != entry["id"] for e in remaining)
def test_unique_constraint(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH)
r = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH)
assert r.status_code == 409
class TestInstrumentNotes:
def test_add_and_list_notes(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
json={"note": "tricky B part"}, headers=AUTH)
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
json={"note": "watch the timing"}, headers=AUTH)
notes = client.get(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes').get_json()
assert len(notes) == 2
def test_update_note(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
json={"note": "old"}, headers=AUTH).get_json()
r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}',
json={"note": "new"}, headers=AUTH)
assert r.get_json()["note"] == "new"
def test_delete_note(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
json={"note": "to delete"}, headers=AUTH).get_json()
r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}',
headers=AUTH)
assert r.status_code == 200
def test_notes_included_in_entry_to_dict(self, client):
tune = make_tune(client)
banjo_id = get_instrument_id(client, "banjo")
entry = client.post(f'/tunes/{tune["id"]}/instruments',
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
json={"note": "my note"}, headers=AUTH)
# Notes appear in the instrument entry and in the parent tune
tune_data = client.get(f'/tunes/{tune["id"]}').get_json()
inst_entry = tune_data["instruments"][0]
assert len(inst_entry["notes"]) == 1
assert inst_entry["notes"][0]["note"] == "my note"

70
tests/test_sources.py Normal file
View file

@ -0,0 +1,70 @@
"""Tests for /sources endpoints."""
from tests.conftest import AUTH, BAD_AUTH
def create_source(client, name="Doc Watson"):
r = client.post("/sources/", json={"name": name}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
class TestSources:
def test_create(self, client):
s = create_source(client)
assert s["name"] == "Doc Watson"
assert "id" in s
def test_list(self, client):
create_source(client, "Doc Watson")
create_source(client, "Tony Rice")
r = client.get("/sources/")
assert r.status_code == 200
names = [s["name"] for s in r.get_json()]
assert "Doc Watson" in names
assert "Tony Rice" in names
def test_list_is_public(self, client):
r = client.get("/sources/")
assert r.status_code == 200
def test_create_requires_auth(self, client):
r = client.post("/sources/", json={"name": "test"})
assert r.status_code == 401
def test_update(self, client):
s = create_source(client)
r = client.patch(f'/sources/{s["id"]}', json={"name": "Doc Watson Jr."}, headers=AUTH)
assert r.status_code == 200
assert r.get_json()["name"] == "Doc Watson Jr."
def test_delete(self, client):
s = create_source(client)
r = client.delete(f'/sources/{s["id"]}', headers=AUTH)
assert r.status_code == 200
remaining = [x["id"] for x in client.get("/sources/").get_json()]
assert s["id"] not in remaining
def test_tune_with_source(self, client):
s = create_source(client, "Clarence Ashley")
r = client.post("/tunes/", json={"name": "Coo Coo Bird", "key": "D", "source_id": s["id"]}, headers=AUTH)
assert r.status_code == 201
tune = r.get_json()
assert tune["source"]["name"] == "Clarence Ashley"
assert tune["source_id"] == s["id"]
def test_filter_by_source(self, client):
s = create_source(client, "Clarence Ashley")
client.post("/tunes/", json={"name": "Coo Coo Bird", "source_id": s["id"]}, headers=AUTH)
client.post("/tunes/", json={"name": "Salt Creek"}, headers=AUTH)
r = client.get(f'/tunes/?source_id={s["id"]}')
results = r.get_json()
assert len(results) == 1
assert results[0]["name"] == "Coo Coo Bird"
def test_filter_by_modal(self, client):
client.post("/tunes/", json={"name": "Modal Tune", "modal": True}, headers=AUTH)
client.post("/tunes/", json={"name": "Regular Tune", "modal": False}, headers=AUTH)
r = client.get("/tunes/?modal=true")
names = [t["name"] for t in r.get_json()]
assert "Modal Tune" in names
assert "Regular Tune" not in names

133
tests/test_sub.py Normal file
View file

@ -0,0 +1,133 @@
"""Tests for notes, references, and musicians sub-resource endpoints."""
from tests.conftest import AUTH
def make_tune(client):
r = client.post("/tunes/", json={"name": "Sub Test Tune", "key": "G"}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
def make_musician(client, name="Doc Watson"):
r = client.post("/musicians/", json={"name": name}, headers=AUTH)
assert r.status_code == 201
return r.get_json()
# ── Notes ──────────────────────────────────────────────────────
class TestNotes:
def test_add_note(self, client):
tune = make_tune(client)
r = client.post(f'/tunes/{tune["id"]}/notes',
json={"note": "remember the B part"}, headers=AUTH)
assert r.status_code == 201
assert r.get_json()["note"] == "remember the B part"
def test_list_notes(self, client):
tune = make_tune(client)
client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 1"}, headers=AUTH)
client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 2"}, headers=AUTH)
r = client.get(f'/tunes/{tune["id"]}/notes', headers=AUTH)
assert len(r.get_json()) == 2
def test_update_note(self, client):
tune = make_tune(client)
note = client.post(f'/tunes/{tune["id"]}/notes',
json={"note": "old"}, headers=AUTH).get_json()
r = client.patch(f'/tunes/{tune["id"]}/notes/{note["id"]}',
json={"note": "new"}, headers=AUTH)
assert r.get_json()["note"] == "new"
def test_delete_note(self, client):
tune = make_tune(client)
note = client.post(f'/tunes/{tune["id"]}/notes',
json={"note": "to delete"}, headers=AUTH).get_json()
r = client.delete(f'/tunes/{tune["id"]}/notes/{note["id"]}', headers=AUTH)
assert r.status_code == 200
def test_note_on_missing_tune_returns_404(self, client):
r = client.post("/tunes/99999/notes", json={"note": "x"}, headers=AUTH)
assert r.status_code == 404
# ── References ────────────────────────────────────────────────
class TestReferences:
def test_add_reference(self, client):
tune = make_tune(client)
r = client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://youtube.com/abc", "site": "YouTube"},
headers=AUTH)
assert r.status_code == 201
assert r.get_json()["musicians"] == []
def test_add_reference_with_musicians(self, client):
tune = make_tune(client)
m1 = make_musician(client, "Doc Watson")
m2 = make_musician(client, "Merle Watson")
r = client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://example.com", "site": "YouTube",
"musicians": [{"id": m1["id"]}, {"id": m2["id"]}]},
headers=AUTH)
assert r.status_code == 201
names = [m["name"] for m in r.get_json()["musicians"]]
assert "Doc Watson" in names
assert "Merle Watson" in names
def test_list_references(self, client):
tune = make_tune(client)
client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://a.com"}, headers=AUTH)
client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://b.com"}, headers=AUTH)
r = client.get(f'/tunes/{tune["id"]}/references', headers=AUTH)
assert len(r.get_json()) == 2
def test_update_reference(self, client):
tune = make_tune(client)
ref = client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://old.com", "site": "Old"},
headers=AUTH).get_json()
r = client.patch(f'/tunes/{tune["id"]}/references/{ref["id"]}',
json={"site": "New"}, headers=AUTH)
assert r.get_json()["site"] == "New"
def test_delete_reference(self, client):
tune = make_tune(client)
ref = client.post(f'/tunes/{tune["id"]}/references',
json={"link": "https://del.com"}, headers=AUTH).get_json()
r = client.delete(f'/tunes/{tune["id"]}/references/{ref["id"]}', headers=AUTH)
assert r.status_code == 200
# ── Musicians on references ───────────────────────────────────
class TestReferenceMusicians:
def _make_ref(self, client, tune_id):
r = client.post(f'/tunes/{tune_id}/references',
json={"link": "https://example.com"}, headers=AUTH)
return r.get_json()
def test_add_musician_to_reference(self, client):
tune = make_tune(client)
ref = self._make_ref(client, tune["id"])
m = make_musician(client, "Tony Rice")
r = client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians',
json={"musician_id": m["id"]}, headers=AUTH)
assert r.status_code == 200
names = [x["name"] for x in r.get_json()["musicians"]]
assert "Tony Rice" in names
def test_remove_musician_from_reference(self, client):
tune = make_tune(client)
m = make_musician(client, "Tony Rice")
ref = self._make_ref(client, tune["id"])
# add musician via the join endpoint
client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians',
json={"musician_id": m["id"]}, headers=AUTH)
r = client.delete(
f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians/{m["id"]}',
headers=AUTH)
assert r.status_code == 200
assert r.get_json()["musicians"] == []

188
tests/test_tunes.py Normal file
View file

@ -0,0 +1,188 @@
"""Tests for /tunes endpoints."""
from tests.conftest import AUTH, BAD_AUTH
def create_tune(client, **kwargs):
data = {"name": "Cluck Old Hen", "key": "A", **kwargs}
r = client.post("/tunes/", json=data, headers=AUTH)
assert r.status_code == 201, r.get_json()
return r.get_json()
def get_instrument_id(client, name):
instruments = client.get("/instruments").get_json()
return next(i["id"] for i in instruments if i["name"] == name)
# ── Auth ──────────────────────────────────────────────────────
class TestAuth:
def test_missing_key_rejected_on_write(self, client):
r = client.post("/tunes/", json={"name": "Test"})
assert r.status_code == 401
def test_wrong_key_rejected_on_write(self, client):
r = client.post("/tunes/", json={"name": "Test"}, headers=BAD_AUTH)
assert r.status_code == 401
def test_reads_are_public(self, client):
assert client.get("/tunes/").status_code == 200
def test_correct_key_accepted_on_write(self, client):
r = client.post("/tunes/", json={"name": "Test"}, headers=AUTH)
assert r.status_code == 201
# ── Health ────────────────────────────────────────────────────
class TestHealth:
def test_health(self, client):
assert client.get("/health").get_json()["status"] == "ok"
# ── Instruments lookup ────────────────────────────────────────
class TestInstrumentsLookup:
def test_list_instruments(self, client):
names = [i["name"] for i in client.get("/instruments").get_json()]
assert "banjo" in names
assert "fiddle" in names
# ── Tune CRUD ─────────────────────────────────────────────────
class TestTuneCRUD:
def test_create_minimal(self, client):
tune = create_tune(client)
assert tune["name"] == "Cluck Old Hen"
assert tune["key"] == "A"
assert tune["modal"] is None
assert tune["source"] is None
assert tune["instruments"] == []
assert tune["notes"] == []
assert tune["references"] == []
def test_create_with_instrument(self, client):
banjo_id = get_instrument_id(client, "banjo")
tuning = client.post("/tunings/", json={"name": "double C"}, headers=AUTH).get_json()
tune = create_tune(client, instruments=[{
"instrument_id": banjo_id,
"tuning_id": tuning["id"],
"callable": True,
}])
assert len(tune["instruments"]) == 1
assert tune["instruments"][0]["instrument_name"] == "banjo"
assert tune["instruments"][0]["tuning"] == "double C"
def test_create_with_two_instruments(self, client):
banjo_id = get_instrument_id(client, "banjo")
fiddle_id = get_instrument_id(client, "fiddle")
tune = create_tune(client, instruments=[
{"instrument_id": banjo_id, "tuning": "double C"},
{"instrument_id": fiddle_id, "tuning": "cross"},
])
assert len(tune["instruments"]) == 2
def test_create_with_notes_and_references(self, client):
tune = create_tune(client,
notes=[{"note": "tricky B part"}],
references=[{"link": "https://youtube.com/x", "site": "YouTube"}],
)
assert tune["notes"][0]["note"] == "tricky B part"
assert tune["references"][0]["site"] == "YouTube"
def test_get_single(self, client):
created = create_tune(client)
r = client.get(f'/tunes/{created["id"]}', headers=AUTH)
assert r.status_code == 200
assert r.get_json()["id"] == created["id"]
def test_get_nonexistent_returns_404(self, client):
assert client.get("/tunes/99999", headers=AUTH).status_code == 404
def test_list_returns_all(self, client):
create_tune(client, name="Tune A")
create_tune(client, name="Tune B")
names = [t["name"] for t in client.get("/tunes/", headers=AUTH).get_json()]
assert "Tune A" in names and "Tune B" in names
def test_patch_core_fields(self, client):
tune = create_tune(client)
r = client.patch(f'/tunes/{tune["id"]}',
json={"key": "G", "name": "New Name", "modal": True},
headers=AUTH)
assert r.status_code == 200
u = r.get_json()
assert u["key"] == "G" and u["name"] == "New Name" and u["modal"] is True
def test_delete(self, client):
tune = create_tune(client)
r = client.delete(f'/tunes/{tune["id"]}', headers=AUTH)
assert r.status_code == 200
assert client.get(f'/tunes/{tune["id"]}', headers=AUTH).status_code == 404
def test_delete_cascades_to_instrument_entry(self, client):
from app.models import TuneByInstrument
from app.extensions import db
banjo_id = get_instrument_id(client, "banjo")
tune = create_tune(client, instruments=[{"instrument_id": banjo_id}])
client.delete(f'/tunes/{tune["id"]}', headers=AUTH)
assert db.session.query(TuneByInstrument).filter_by(tune_id=tune["id"]).count() == 0
# ── Filtering ─────────────────────────────────────────────────
class TestFiltering:
def test_filter_by_key(self, client):
create_tune(client, name="D tune", key="D")
create_tune(client, name="G tune", key="G")
results = client.get("/tunes/?key=D", headers=AUTH).get_json()
assert all(t["key"] == "D" for t in results)
def test_search_by_name(self, client):
create_tune(client, name="Whiskey Before Breakfast")
create_tune(client, name="Salt Creek")
results = client.get("/tunes/?search=whiskey", headers=AUTH).get_json()
assert len(results) == 1
assert results[0]["name"] == "Whiskey Before Breakfast"
def test_filter_by_instrument(self, client):
banjo_id = get_instrument_id(client, "banjo")
create_tune(client, name="Has Banjo",
instruments=[{"instrument_id": banjo_id, "tuning": "standard"}])
create_tune(client, name="No Banjo")
results = client.get(f"/tunes/?instrument_id={banjo_id}", headers=AUTH).get_json()
names = [t["name"] for t in results]
assert "Has Banjo" in names and "No Banjo" not in names
def test_filter_by_tuning(self, client):
fiddle_id = get_instrument_id(client, "fiddle")
cross_tuning = client.post("/tunings/", json={"name": "cross"}, headers=AUTH).get_json()
std_tuning = client.post("/tunings/", json={"name": "standard"}, headers=AUTH).get_json()
create_tune(client, name="Cross", instruments=[{"instrument_id": fiddle_id, "tuning_id": cross_tuning["id"]}])
create_tune(client, name="Std", instruments=[{"instrument_id": fiddle_id, "tuning_id": std_tuning["id"]}])
results = client.get(f'/tunes/?instrument_id={fiddle_id}&tuning={cross_tuning["id"]}', headers=AUTH).get_json()
assert len(results) == 1 and results[0]["name"] == "Cross"
def test_filter_callable(self, client):
banjo_id = get_instrument_id(client, "banjo")
create_tune(client, name="Can call", instruments=[{"instrument_id": banjo_id, "callable": True}])
create_tune(client, name="Cannot call", instruments=[{"instrument_id": banjo_id, "callable": False}])
results = client.get(f"/tunes/?instrument_id={banjo_id}&callable=true", headers=AUTH).get_json()
names = [t["name"] for t in results]
assert "Can call" in names and "Cannot call" not in names
def test_filter_to_learn(self, client):
fiddle_id = get_instrument_id(client, "fiddle")
create_tune(client, name="To learn", instruments=[{"instrument_id": fiddle_id, "to_learn": True}])
create_tune(client, name="Learned", instruments=[{"instrument_id": fiddle_id, "to_learn": False}])
results = client.get(f"/tunes/?instrument_id={fiddle_id}&to_learn=true", headers=AUTH).get_json()
names = [t["name"] for t in results]
assert "To learn" in names and "Learned" not in names
def test_filter_modal(self, client):
create_tune(client, name="Modal", modal=True)
create_tune(client, name="Regular", modal=False)
results = client.get("/tunes/?modal=true", headers=AUTH).get_json()
names = [t["name"] for t in results]
assert "Modal" in names and "Regular" not in names