File size: 11,717 Bytes
cb7f2ec a7ffb22 cb7f2ec a7ffb22 cb7f2ec a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | import asyncio
import urllib.parse
import uuid
import httpx
import pytest
from app.auth import AuthUnavailable
from app.main import create_app
from scan_support import OTHER, OWNER, ScanAuth, postgres_server # noqa: F401 (a fixture)
DEV = '33333333-3333-4333-8333-333333333333'
class PrompterAuth(ScanAuth):
"""OWNER and DEV are developers, OTHER is a student; an in-memory math_prompter_texts behind the PostgREST calls."""
developers = frozenset({OWNER + '@test.invalid', DEV + '@test.invalid'})
def __init__(self):
self.rows: list[dict] = []
self.missing = False
self.no_outline = False # before the outline migration
def user(self, token):
return {'id': token, 'email': token + '@test.invalid'} if token in (OWNER, OTHER, DEV) else None
def _admin(self, path, method='GET', body=None, prefer=''):
if self.missing:
raise AuthUnavailable('supabase answered 404: relation does not exist')
if self.no_outline and ('outline' in path or 'outline' in (body or {})):
raise AuthUnavailable('supabase answered 400: column math_prompter_texts.outline does not exist')
table, _, query = path.partition('?')
assert table == '/rest/v1/math_prompter_texts'
q = dict(urllib.parse.parse_qsl(query))
match = [r for r in self.rows if all(r[k] == q[k].removeprefix('eq.') for k in ('id', 'user_id') if k in q)]
if method == 'POST':
row = {**body, 'id': str(uuid.uuid4())}
self.rows.append(row)
return [row]
if method == 'PATCH':
for r in match:
r.update(body)
return match
if method == 'DELETE':
self.rows = [r for r in self.rows if r not in match]
return None
return sorted(match, key=lambda r: r['updated_at'], reverse=True)
def call(app, user, method, url, **kw):
async def go():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test',
headers={'Authorization': f'Bearer {user}'}) as c:
return await c.request(method, url, **kw)
return asyncio.run(go())
@pytest.fixture
def app():
a = create_app()
a.state.auth = PrompterAuth()
return a
def test_developer_saves_lists_updates_and_deletes(app):
r = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': '', 'body': ' ืฉืืื ืืืืื\nืฉืืจื ืฉื ืืื'})
assert r.status_code == 200
first = r.json()['text']
assert first['title'] == 'ืฉืืื ืืืืื' # no title: the first line
second = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ืคืชืืื', 'body': 'ืืงืกื'}).json()['text']
texts = call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']
assert [t['id'] for t in texts] == [second['id'], first['id']] # newest first
r = call(app, OWNER, 'PUT', f"/api/prompter/texts/{first['id']}", json={'title': 'ืืืฉ', 'body': 'ืืืฃ ืืืฉ'})
assert r.json()['text']['title'] == 'ืืืฉ' and r.json()['text']['body'] == 'ืืืฃ ืืืฉ'
assert call(app, OWNER, 'DELETE', f"/api/prompter/texts/{second['id']}").status_code == 200
assert [t['title'] for t in call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']] == ['ืืืฉ']
def test_each_developer_sees_only_their_own(app):
mine = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ืฉืื', 'body': 'x'}).json()['text']
assert call(app, DEV, 'GET', '/api/prompter/texts').json()['texts'] == []
assert call(app, DEV, 'PUT', f"/api/prompter/texts/{mine['id']}", json={'title': 'a', 'body': 'b'}).status_code == 404
call(app, DEV, 'DELETE', f"/api/prompter/texts/{mine['id']}")
assert len(call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']) == 1
def test_students_and_bad_input_are_refused(app):
assert call(app, OTHER, 'GET', '/api/prompter/texts').status_code == 403
assert call(app, OTHER, 'POST', '/api/prompter/texts', json={'title': 'a', 'body': 'b'}).status_code == 403
assert call(app, 'nobody', 'GET', '/api/prompter/texts').status_code == 401
assert call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'a', 'body': ' '}).status_code == 400
assert call(app, OWNER, 'POST', '/api/prompter/texts', json={'body': 'x' * 100_001}).status_code == 413
assert call(app, OWNER, 'PUT', '/api/prompter/texts/not-an-id', json={'body': 'x'}).status_code == 404
def test_long_title_is_cut_and_missing_table_is_503(app):
r = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 'ื' * 300, 'body': 'x'})
assert len(r.json()['text']['title']) == 120
app.state.auth.missing = True
assert call(app, OWNER, 'GET', '/api/prompter/texts').status_code == 503
def test_migration_on_real_postgres(postgres_server): # noqa: F811
"""The table exists, keeps its limits, and only the server's key may use it."""
psycopg = pytest.importorskip("psycopg")
from pathlib import Path
from scan_support import BOOTSTRAP
database = "prm_" + uuid.uuid4().hex
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("create database " + database)
uri = postgres_server.get_uri(database=database)
try:
with psycopg.connect(uri, autocommit=True) as c:
c.execute(BOOTSTRAP)
c.execute(Path("supabase/migrations/20261106130000_math_prompter_texts.sql").read_text())
c.execute("insert into auth.users values(%s)", (OWNER,))
c.execute("set role service_role")
c.execute("insert into math_prompter_texts(user_id,title,body) values(%s,'a','b')", (OWNER,))
with pytest.raises(psycopg.errors.CheckViolation):
c.execute("insert into math_prompter_texts(user_id,title,body) values(%s,'','b')", (OWNER,))
c.execute("reset role")
c.execute("set role authenticated")
with pytest.raises(psycopg.errors.InsufficientPrivilege):
c.execute("select * from math_prompter_texts")
c.execute("reset role")
c.execute("delete from auth.users") # an account's texts go with it
assert c.execute("select count(*) from math_prompter_texts").fetchone()[0] == 0
finally:
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("drop database " + database + " with (force)")
SECTIONS = [{"part": "Part 1 โ Why", "heading": "Slide 1 โ From Data to Decisions", "time": "Opening ยท 35 seconds",
"reminders": ["Good morning", "BI + AI in daily work"]},
{"part": "", "heading": "Slide 2 โ Thank You", "time": "", "reminders": [" Use the data we have ", ""]},
{"part": "", "heading": "", "time": "", "reminders": ["dropped: no heading"]}]
def test_outline_text_is_a_slide_script():
from app.prompter import outline_text
assert outline_text(SECTIONS) == ("Part 1 โ Why\nSlide 1 โ From Data to Decisions\nOpening ยท 35 seconds\n"
"โข Good morning\nโข BI + AI in daily work\n\nSlide 2 โ Thank You\nโข Use the data we have")
def test_claude_writes_the_outline_and_it_is_kept(app, monkeypatch):
from app import ai_usage
from app.prompter import outline_text
seen, recorded = [], []
app.state.prompter_outliner = lambda body: (seen.append(body) or outline_text(SECTIONS), {"model": "m"})
monkeypatch.setattr(ai_usage, "record", lambda auth, user, feature, usage: recorded.append((user, feature)))
text = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 't', 'body': 'Slide 1 โ a\nhello'}).json()['text']
r = call(app, OWNER, 'POST', f"/api/prompter/texts/{text['id']}/outline")
assert r.status_code == 200 and r.json()['text']['outline'].startswith("Part 1 โ Why\nSlide 1")
assert seen == ['Slide 1 โ a\nhello'] and recorded == [(OWNER, "prompter")]
assert call(app, DEV, 'POST', f"/api/prompter/texts/{text['id']}/outline").status_code == 404 # not theirs
assert call(app, OTHER, 'POST', f"/api/prompter/texts/{text['id']}/outline").status_code == 403
url = f"/api/prompter/texts/{text['id']}"
# a new title keeps it; an edited outline is saved; a changed text without an outline drops it
assert call(app, OWNER, 'PUT', url, json={'title': 'new', 'body': 'Slide 1 โ a\nhello'}).json()['text']['outline']
assert call(app, OWNER, 'PUT', url, json={'title': 'new', 'body': 'Slide 1 โ a\nhello',
'outline': 'Slide 1 โ a\nโข mine'}).json()['text']['outline'] == 'Slide 1 โ a\nโข mine'
assert call(app, OWNER, 'PUT', url, json={'title': 'new', 'body': 'Slide 1 โ b\nbye'}).json()['text']['outline'] is None
assert call(app, OWNER, 'PUT', url, json={'body': 'x', 'outline': 'y' * 20_001}).status_code == 413
assert call(app, OWNER, 'PUT', url, json={'body': 'x', 'outline': ' '}).json()['text']['outline'] is None
def test_outline_without_claude_is_503(app, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
text = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 't', 'body': 'x'}).json()['text']
assert call(app, OWNER, 'POST', f"/api/prompter/texts/{text['id']}/outline").status_code == 503
def test_outline_migration_on_real_postgres(postgres_server): # noqa: F811
psycopg = pytest.importorskip("psycopg")
from pathlib import Path
from scan_support import BOOTSTRAP
database = "pro_" + uuid.uuid4().hex
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("create database " + database)
try:
with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
c.execute(BOOTSTRAP)
for name in ("20261103220000_math_ai_usage", "20261106130000_math_prompter_texts",
"20261107130000_math_prompter_outline"):
c.execute(Path(f"supabase/migrations/{name}.sql").read_text())
c.execute("insert into auth.users values(%s)", (OWNER,))
c.execute("set role service_role")
c.execute("insert into math_prompter_texts(user_id,title,body) values(%s,'a','b')", (OWNER,))
c.execute("update math_prompter_texts set outline='โข x'")
with pytest.raises(psycopg.errors.CheckViolation):
c.execute("update math_prompter_texts set outline=''")
c.execute("insert into math_ai_usage(feature,model) values('prompter','m')")
with pytest.raises(psycopg.errors.CheckViolation):
c.execute("insert into math_ai_usage(feature,model) values('other','m')")
finally:
with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
c.execute("drop database " + database + " with (force)")
def test_before_the_outline_migration_texts_still_work(app):
app.state.auth.no_outline = True
app.state.prompter_outliner = lambda body: ("Slide 1 โ a\nโข x", None)
text = call(app, OWNER, 'POST', '/api/prompter/texts', json={'title': 't', 'body': 'x'}).json()['text']
assert 'outline' not in text
assert [t['id'] for t in call(app, OWNER, 'GET', '/api/prompter/texts').json()['texts']] == [text['id']]
assert call(app, OWNER, 'PUT', f"/api/prompter/texts/{text['id']}", json={'body': 'y'}).json()['text']['body'] == 'y'
assert call(app, OWNER, 'POST', f"/api/prompter/texts/{text['id']}/outline").status_code == 503
|