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:
parent
504631763b
commit
6791ea22b2
4 changed files with 133 additions and 12 deletions
|
|
@ -12,11 +12,10 @@ from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.store import get_db
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -118,7 +117,13 @@ class FeedbackResponse(BaseModel):
|
||||||
issue_url: str
|
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)
|
@router.post("", response_model=FeedbackResponse)
|
||||||
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ currentTab?: string }>()
|
const props = defineProps<{ currentTab?: string }>()
|
||||||
|
|
||||||
// Check if feedback is enabled (token configured) — we try once and cache
|
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
||||||
const enabled = ref(true) // optimistic; 503 from API will hide on next attempt
|
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 open = ref(false)
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
|
|
@ -208,11 +217,6 @@ async function submit() {
|
||||||
submitter: form.value.submitter.trim(),
|
submitter: form.value.submitter.trim(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.status === 503) {
|
|
||||||
enabled.value = false // token not configured — hide the button
|
|
||||||
close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||||
submitError.value = err.detail ?? 'Submission failed.'
|
submitError.value = err.detail ?? 'Submission failed.'
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ dependencies = [
|
||||||
"numpy>=1.25",
|
"numpy>=1.25",
|
||||||
"pyzbar>=0.1.9",
|
"pyzbar>=0.1.9",
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
# HTTP client
|
# HTTP clients
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"requests>=2.31",
|
||||||
# CircuitForge shared scaffold
|
# CircuitForge shared scaffold
|
||||||
"circuitforge-core>=0.6.0",
|
"circuitforge-core>=0.6.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
111
tests/api/test_feedback.py
Normal file
111
tests/api/test_feedback.py
Normal 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
|
||||||
Loading…
Reference in a new issue