DevPlace CI / test (push) Failing after 58m59s
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.
Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.
Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.
Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.
Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.
Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.
Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.
Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.
Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.
Full suite: 2989 passed, 1 skipped.
165 lines
5.3 KiB
Python
165 lines
5.3 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from devplacepy.database import get_table
|
|
from devplacepy.services.openai_gateway import quota as q
|
|
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
|
|
from devplacepy.utils import generate_uid
|
|
|
|
_counter = [0]
|
|
|
|
|
|
def _owner():
|
|
_counter[0] += 1
|
|
return f"quotauser{_counter[0]}-{generate_uid()}"
|
|
|
|
|
|
def _burn(owner_id, app_reference, cost, minutes_ago=0):
|
|
stamp = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
|
get_table(GATEWAY_LEDGER).insert(
|
|
{
|
|
"uid": generate_uid(),
|
|
"owner_kind": "user",
|
|
"owner_id": owner_id,
|
|
"app_reference": app_reference,
|
|
"cost_usd": cost,
|
|
"created_at": stamp.isoformat(),
|
|
}
|
|
)
|
|
|
|
|
|
def test_reset_requires_no_dimension(local_db):
|
|
assert q.QuotaResetIn().owner_kind is None
|
|
assert q.QuotaResetIn().owner_id is None
|
|
assert q.QuotaResetIn().app_reference is None
|
|
|
|
|
|
def test_reset_rejects_an_unknown_owner_kind(local_db):
|
|
with pytest.raises(ValidationError):
|
|
q.QuotaResetIn(owner_kind="wizard")
|
|
|
|
|
|
def test_reset_rejects_a_malformed_app_reference(local_db):
|
|
with pytest.raises(ValidationError):
|
|
q.QuotaResetIn(app_reference="not a valid app!")
|
|
|
|
|
|
def test_reset_normalizes_blanks_to_wildcards(local_db):
|
|
payload = q.QuotaResetIn(owner_kind="", owner_id=" ", app_reference="")
|
|
assert payload.owner_kind is None
|
|
assert payload.owner_id is None
|
|
assert payload.app_reference is None
|
|
|
|
|
|
def test_spend_counts_before_any_reset(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
assert q.spent_24h("user", owner, "appa") == 1.5
|
|
|
|
|
|
def test_a_scoped_reset_clears_that_scope(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
|
|
|
|
def test_a_scoped_reset_leaves_another_caller_alone(local_db):
|
|
first, second = _owner(), _owner()
|
|
_burn(first, "appa", 1.5)
|
|
_burn(second, "appa", 2.0)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=first, app_reference="appa"))
|
|
assert q.spent_24h("user", first, "appa") == 0.0
|
|
assert q.spent_24h("user", second, "appa") == 2.0
|
|
|
|
|
|
def test_a_scoped_reset_leaves_another_app_alone(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
_burn(owner, "appb", 2.0)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
assert q.spent_24h("user", owner, "appb") == 2.0
|
|
|
|
|
|
def test_a_global_reset_clears_every_scope(local_db):
|
|
first, second = _owner(), _owner()
|
|
_burn(first, "appa", 1.5)
|
|
_burn(second, "appb", 2.0)
|
|
q.reset(q.QuotaResetIn())
|
|
assert q.spent_24h("user", first, "appa") == 0.0
|
|
assert q.spent_24h("user", second, "appb") == 0.0
|
|
|
|
|
|
def test_spend_after_a_reset_counts_again(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
_burn(owner, "appa", 0.75)
|
|
assert q.spent_24h("user", owner, "appa") == 0.75
|
|
|
|
|
|
def test_a_reset_keeps_the_usage_history(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
before = get_table(GATEWAY_LEDGER).count(owner_id=owner)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
assert get_table(GATEWAY_LEDGER).count(owner_id=owner) == before
|
|
|
|
|
|
def test_a_narrower_reset_does_not_clear_a_broader_scope(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa"))
|
|
assert q.spent_24h("user", owner, None) == 1.5
|
|
|
|
|
|
def test_a_broader_reset_clears_a_narrower_scope(local_db):
|
|
owner = _owner()
|
|
_burn(owner, "appa", 1.5)
|
|
q.reset(q.QuotaResetIn(owner_kind="user", owner_id=owner))
|
|
assert q.spent_24h("user", owner, "appa") == 0.0
|
|
|
|
|
|
def test_resetting_the_same_scope_twice_reuses_one_row(local_db):
|
|
owner = _owner()
|
|
scope = q.QuotaResetIn(owner_kind="user", owner_id=owner, app_reference="appa")
|
|
first = q.reset(scope)
|
|
second = q.reset(scope)
|
|
assert first["uid"] == second["uid"]
|
|
assert second["reset_at"] >= first["reset_at"]
|
|
|
|
|
|
def test_a_rule_scope_is_unblocked_by_a_reset(local_db):
|
|
owner = _owner()
|
|
q.quota_rule_store.set(
|
|
q.QuotaRuleIn(
|
|
owner_kind="user", owner_id=owner, app_reference="appa", limit_usd=1.0
|
|
),
|
|
created_by="test",
|
|
)
|
|
_burn(owner, "appa", 1.5)
|
|
limit, scope, rule = q.resolve("user", owner, "appa", {})
|
|
assert q.spent_24h(*scope) >= limit
|
|
q.reset(
|
|
q.QuotaResetIn(
|
|
owner_kind=scope[0], owner_id=scope[1], app_reference=scope[2]
|
|
)
|
|
)
|
|
assert q.spent_24h(*scope) < limit
|
|
q.quota_rule_store.remove(rule.uid)
|
|
|
|
|
|
def test_scope_label_reads_like_the_rule_label(local_db):
|
|
scope = {"owner_kind": "user", "owner_id": "u1", "app_reference": "appa"}
|
|
assert q.scope_label(scope) == "role=user, user=u1, app=appa"
|
|
|
|
|
|
def test_scope_label_falls_back_for_a_wildcard_scope(local_db):
|
|
empty = {"owner_kind": None, "owner_id": None, "app_reference": None}
|
|
assert q.scope_label(empty, fallback="every caller") == "every caller"
|