feat: complete feedback button — status probe, requests dep, tests

- feedback.py: add GET /feedback/status endpoint (returns {enabled: bool})
  so frontend can probe on mount instead of optimistic-enable; remove
  unused get_db import
- FeedbackButton.vue: probe /feedback/status on mount, start hidden;
  drop redundant 503-hide path (status probe makes it redundant)
- pyproject.toml: declare requests>=2.31 (used by feedback.py Forgejo calls)
- tests/api/test_feedback.py: 7 tests — status endpoint (no-token, token,
  demo mode), POST 503/403, happy path with mocked Forgejo, 502 on error
This commit is contained in:
pyr0ball 2026-04-03 16:52:40 -07:00
parent 504631763b
commit 6791ea22b2
4 changed files with 133 additions and 12 deletions

View file

@ -12,11 +12,10 @@ from pathlib import Path
from typing import Literal
import requests
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import settings
from app.db.store import get_db
router = APIRouter()
@ -118,7 +117,13 @@ class FeedbackResponse(BaseModel):
issue_url: str
# ── Route ──────────────────────────────────────────────────────────────────────
# ── Routes ─────────────────────────────────────────────────────────────────────
@router.get("/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
@router.post("", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:

View file

@ -136,12 +136,21 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentTab?: string }>()
// Check if feedback is enabled (token configured) we try once and cache
const enabled = ref(true) // optimistic; 503 from API will hide on next attempt
// Probe once on mount hidden until confirmed enabled so button never flashes
const enabled = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/v1/feedback/status')
if (res.ok) {
const data = await res.json()
enabled.value = data.enabled === true
}
} catch { /* network error — stay hidden */ }
})
const open = ref(false)
const step = ref(1)
@ -208,11 +217,6 @@ async function submit() {
submitter: form.value.submitter.trim(),
}),
})
if (res.status === 503) {
enabled.value = false // token not configured hide the button
close()
return
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
submitError.value = err.detail ?? 'Submission failed.'

View file

@ -19,8 +19,9 @@ dependencies = [
"numpy>=1.25",
"pyzbar>=0.1.9",
"Pillow>=10.0",
# HTTP client
# HTTP clients
"httpx>=0.27",
"requests>=2.31",
# CircuitForge shared scaffold
"circuitforge-core>=0.6.0",
]

111
tests/api/test_feedback.py Normal file
View file

@ -0,0 +1,111 @@
"""Tests for the /feedback endpoints."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# ── /feedback/status ──────────────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
def test_status_enabled_when_token_set(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": True}
def test_status_disabled_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /feedback ────────────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
res = client.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 503
def test_submit_returns_403_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
res = client.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 403
def test_submit_creates_issue(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
# Mock the two Forgejo HTTP calls: label fetch + issue create
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "bug"},
]
issue_response = MagicMock()
issue_response.ok = True
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
res = client.post("/api/v1/feedback", json={
"title": "Something broke",
"description": "It broke when I tapped X",
"type": "bug",
"repro": "1. Open app\n2. Tap X",
"tab": "pantry",
})
assert res.status_code == 200
data = res.json()
assert data["issue_number"] == 42
assert data["issue_url"] == "https://example.com/issues/42"
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = []
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "forbidden"
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
res = client.post("/api/v1/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})
assert res.status_code == 502