Compare commits
No commits in common. "7672dd758a72c58530516aac5bb9b304be66d2c7" and "f7d5b20aa5a1bc2cd8bef1771e8848b7e7c32e75" have entirely different histories.
7672dd758a
...
f7d5b20aa5
7 changed files with 135 additions and 293 deletions
44
README.md
44
README.md
|
|
@ -4,50 +4,6 @@
|
||||||
|
|
||||||
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
|
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
|
||||||
|
|
||||||
## Quick install (self-hosted)
|
|
||||||
|
|
||||||
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# One-line install — clones to ~/snipe by default
|
|
||||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
|
|
||||||
|
|
||||||
# Or clone manually and run the script:
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
|
||||||
bash snipe/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open **http://localhost:8509**.
|
|
||||||
|
|
||||||
### Manual setup (if you prefer)
|
|
||||||
|
|
||||||
Snipe's API image is built from a parent context that includes `circuitforge-core`. Both repos must sit as siblings in the same directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
workspace/
|
|
||||||
├── snipe/ ← this repo
|
|
||||||
└── circuitforge-core/ ← required sibling
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir snipe-workspace && cd snipe-workspace
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
|
|
||||||
cd snipe
|
|
||||||
cp .env.example .env # edit if you have eBay API credentials (optional)
|
|
||||||
./manage.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional: eBay API credentials
|
|
||||||
|
|
||||||
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and inline seller account age (no extra scrape needed):
|
|
||||||
|
|
||||||
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
|
|
||||||
2. Copy your Production **App ID** and **Cert ID** into `.env`
|
|
||||||
3. Restart: `./manage.sh restart`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Snipe has two layers that work together:
|
Snipe has two layers that work together:
|
||||||
|
|
|
||||||
126
api/main.py
126
api/main.py
|
|
@ -11,7 +11,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import platform as _platform
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import requests as _requests
|
||||||
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -19,7 +24,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import SearchFilters
|
||||||
|
|
@ -77,12 +81,6 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
_feedback_router = _make_feedback_router(
|
|
||||||
repo="Circuit-Forge/snipe",
|
|
||||||
product="snipe",
|
|
||||||
)
|
|
||||||
app.include_router(_feedback_router, prefix="/api/feedback")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
@ -647,3 +645,117 @@ async def import_blocklist(
|
||||||
return {"imported": imported, "errors": errors}
|
return {"imported": imported, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feedback ────────────────────────────────────────────────────────────────
|
||||||
|
# Creates Forgejo issues from in-app beta feedback.
|
||||||
|
# Silently disabled when FORGEJO_API_TOKEN is not set.
|
||||||
|
|
||||||
|
_FEEDBACK_LABEL_COLORS = {
|
||||||
|
"beta-feedback": "#0075ca",
|
||||||
|
"needs-triage": "#e4e669",
|
||||||
|
"bug": "#d73a4a",
|
||||||
|
"feature-request": "#a2eeef",
|
||||||
|
"question": "#d876e3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fb_headers() -> dict:
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_feedback_labels(names: list[str]) -> list[int]:
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
||||||
|
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
|
||||||
|
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
|
||||||
|
ids: list[int] = []
|
||||||
|
for name in names:
|
||||||
|
if name in existing:
|
||||||
|
ids.append(existing[name])
|
||||||
|
else:
|
||||||
|
r = _requests.post(
|
||||||
|
f"{base}/repos/{repo}/labels",
|
||||||
|
headers=_fb_headers(),
|
||||||
|
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
ids.append(r.json()["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: Literal["bug", "feature", "other"] = "other"
|
||||||
|
repro: str = ""
|
||||||
|
view: str = "unknown"
|
||||||
|
submitter: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackResponse(BaseModel):
|
||||||
|
issue_number: int
|
||||||
|
issue_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feedback/status")
|
||||||
|
def feedback_status() -> dict:
|
||||||
|
"""Return whether feedback submission is configured on this instance."""
|
||||||
|
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
|
||||||
|
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feedback", response_model=FeedbackResponse)
|
||||||
|
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||||
|
"""File a Forgejo issue from in-app feedback."""
|
||||||
|
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
|
||||||
|
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
|
||||||
|
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--always"],
|
||||||
|
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
version = "dev"
|
||||||
|
|
||||||
|
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
|
||||||
|
body_lines = [
|
||||||
|
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
|
||||||
|
"",
|
||||||
|
payload.description,
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if payload.type == "bug" and payload.repro:
|
||||||
|
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
|
||||||
|
body_lines += [
|
||||||
|
"### Context", "",
|
||||||
|
f"- **view:** {payload.view}",
|
||||||
|
f"- **version:** {version}",
|
||||||
|
f"- **platform:** {_platform.platform()}",
|
||||||
|
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if payload.submitter:
|
||||||
|
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
|
||||||
|
|
||||||
|
labels = ["beta-feedback", "needs-triage",
|
||||||
|
{"bug": "bug", "feature": "feature-request"}.get(payload.type, "question")]
|
||||||
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
|
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
|
||||||
|
|
||||||
|
label_ids = _ensure_feedback_labels(labels)
|
||||||
|
resp = _requests.post(
|
||||||
|
f"{base}/repos/{repo}/issues",
|
||||||
|
headers=_fb_headers(),
|
||||||
|
json={"title": payload.title, "body": "\n".join(body_lines), "labels": label_ids},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
# compose.override.yml — dev-only additions (auto-applied by Docker Compose in dev).
|
|
||||||
# Safe to delete on a self-hosted machine — compose.yml is self-contained.
|
|
||||||
#
|
|
||||||
# What this adds over compose.yml:
|
|
||||||
# - Live source mounts so code changes take effect without rebuilding images
|
|
||||||
# - RELOAD=true to enable uvicorn --reload for the API
|
|
||||||
# - NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
|
|
||||||
# pick up cf-core changes. Mounting it as a bind volume would break self-hosted
|
|
||||||
# installs that don't have the sibling directory.
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: snipe/Dockerfile
|
||||||
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
|
- ../circuitforge-core:/app/circuitforge-core
|
||||||
- ./api:/app/snipe/api
|
- ./api:/app/snipe/api
|
||||||
- ./app:/app/snipe/app
|
- ./app:/app/snipe/app
|
||||||
|
- ./data:/app/snipe/data
|
||||||
- ./tests:/app/snipe/tests
|
- ./tests:/app/snipe/tests
|
||||||
environment:
|
environment:
|
||||||
- RELOAD=true
|
- RELOAD=true
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/web/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./web/src:/app/src # not used at runtime but keeps override valid
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: snipe/Dockerfile
|
dockerfile: snipe/Dockerfile
|
||||||
# Host networking lets nginx (in the web container) reach the API at
|
ports:
|
||||||
# 172.17.0.1:8510 (the Docker bridge gateway). Required — nginx.conf
|
- "8510:8510"
|
||||||
# is baked into the image and hard-codes that address.
|
|
||||||
network_mode: host
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/snipe/data
|
- ./data:/app/snipe/data
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
93
install.sh
93
install.sh
|
|
@ -1,93 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Snipe — self-hosted install script
|
|
||||||
# Clones Snipe and its shared library (circuitforge-core) into a workspace,
|
|
||||||
# then starts the Docker stack.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash install.sh # installs to ~/snipe
|
|
||||||
# bash install.sh /opt/snipe # custom install directory
|
|
||||||
#
|
|
||||||
# Requirements: Docker with Compose plugin, Git
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${1:-$HOME/snipe}"
|
|
||||||
FORGEJO="https://git.opensourcesolarpunk.com/Circuit-Forge"
|
|
||||||
|
|
||||||
info() { echo " [snipe] $*"; }
|
|
||||||
ok() { echo "✓ $*"; }
|
|
||||||
fail() { echo "✗ $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " Snipe — self-hosted installer"
|
|
||||||
echo " Install directory: $INSTALL_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Pre-flight checks ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || fail "Docker is required. Install from https://docs.docker.com/get-docker/"
|
|
||||||
docker compose version >/dev/null 2>&1 || fail "Docker Compose plugin is required (docker compose, not docker-compose)."
|
|
||||||
command -v git >/dev/null 2>&1 || fail "Git is required."
|
|
||||||
ok "Docker $(docker --version | awk '{print $3}' | tr -d ,) and Git found."
|
|
||||||
|
|
||||||
# ── Clone repos ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# compose.yml builds with context: .. so both repos must be siblings.
|
|
||||||
SNIPE_DIR="$INSTALL_DIR/snipe"
|
|
||||||
CORE_DIR="$INSTALL_DIR/circuitforge-core"
|
|
||||||
|
|
||||||
if [[ -d "$SNIPE_DIR" ]]; then
|
|
||||||
info "Snipe already exists at $SNIPE_DIR — pulling latest..."
|
|
||||||
git -C "$SNIPE_DIR" pull --ff-only
|
|
||||||
else
|
|
||||||
info "Cloning Snipe..."
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
git clone "$FORGEJO/snipe.git" "$SNIPE_DIR"
|
|
||||||
fi
|
|
||||||
ok "Snipe cloned to $SNIPE_DIR"
|
|
||||||
|
|
||||||
if [[ -d "$CORE_DIR" ]]; then
|
|
||||||
info "circuitforge-core already exists — pulling latest..."
|
|
||||||
git -C "$CORE_DIR" pull --ff-only
|
|
||||||
else
|
|
||||||
info "Cloning circuitforge-core (shared library)..."
|
|
||||||
git clone "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
|
|
||||||
fi
|
|
||||||
ok "circuitforge-core cloned to $CORE_DIR"
|
|
||||||
|
|
||||||
# ── Configure environment ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ENV_FILE="$SNIPE_DIR/.env"
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
cp "$SNIPE_DIR/.env.example" "$ENV_FILE"
|
|
||||||
ok ".env created from .env.example"
|
|
||||||
echo ""
|
|
||||||
echo " ┌────────────────────────────────────────────────────────┐"
|
|
||||||
echo " │ Next step: edit $ENV_FILE │"
|
|
||||||
echo " │ │"
|
|
||||||
echo " │ Snipe works out of the box with no API keys. │"
|
|
||||||
echo " │ Add EBAY_APP_ID / EBAY_CERT_ID for faster searches │"
|
|
||||||
echo " │ and full seller account age data (optional). │"
|
|
||||||
echo " └────────────────────────────────────────────────────────┘"
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
info ".env already exists — skipping (delete it to reset)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Build and start ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
info "Building Docker images (first run downloads ~1 GB of dependencies)..."
|
|
||||||
cd "$SNIPE_DIR"
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
info "Starting Snipe..."
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
ok "Snipe is running!"
|
|
||||||
echo ""
|
|
||||||
echo " Web UI: http://localhost:8509"
|
|
||||||
echo " API: http://localhost:8510/docs"
|
|
||||||
echo ""
|
|
||||||
echo " Manage: cd $SNIPE_DIR && ./manage.sh {start|stop|restart|logs|test}"
|
|
||||||
echo ""
|
|
||||||
|
|
@ -78,7 +78,7 @@ case "$cmd" in
|
||||||
test)
|
test)
|
||||||
echo "Running test suite..."
|
echo "Running test suite..."
|
||||||
docker compose -f "$COMPOSE_FILE" exec api \
|
docker compose -f "$COMPOSE_FILE" exec api \
|
||||||
python -m pytest /app/snipe/tests/ -v "${@}"
|
conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
# ── Cloud commands ────────────────────────────────────────────────────────
|
# ── Cloud commands ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""Tests for the shared feedback router (circuitforge-core) mounted in snipe."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from circuitforge_core.api.feedback import make_feedback_router
|
|
||||||
|
|
||||||
|
|
||||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
|
||||||
app = FastAPI()
|
|
||||||
router = make_feedback_router(
|
|
||||||
repo="Circuit-Forge/snipe",
|
|
||||||
product="snipe",
|
|
||||||
demo_mode_fn=demo_mode_fn,
|
|
||||||
)
|
|
||||||
app.include_router(router, prefix="/api/feedback")
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/feedback/status ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_status_disabled_when_no_token(monkeypatch):
|
|
||||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
|
||||||
monkeypatch.delenv("DEMO_MODE", raising=False)
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.get("/api/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")
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.get("/api/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")
|
|
||||||
demo = True
|
|
||||||
client = _make_client(demo_mode_fn=lambda: demo)
|
|
||||||
res = client.get("/api/feedback/status")
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": False}
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/feedback ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_submit_returns_503_when_no_token(monkeypatch):
|
|
||||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
res = client.post("/api/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")
|
|
||||||
demo = True
|
|
||||||
client = _make_client(demo_mode_fn=lambda: demo)
|
|
||||||
res = client.post("/api/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")
|
|
||||||
|
|
||||||
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": 7,
|
|
||||||
"html_url": "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7",
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
|
|
||||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
|
||||||
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Listing scores wrong",
|
|
||||||
"description": "Trust score shows 0 when seller has 1000 feedback",
|
|
||||||
"type": "bug",
|
|
||||||
"repro": "1. Search for anything\n2. Check trust score",
|
|
||||||
"tab": "search",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["issue_number"] == 7
|
|
||||||
assert data["issue_url"] == "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7"
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_returns_502_on_forgejo_error(monkeypatch):
|
|
||||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
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": "question"},
|
|
||||||
]
|
|
||||||
|
|
||||||
bad_response = MagicMock()
|
|
||||||
bad_response.ok = False
|
|
||||||
bad_response.text = "internal server error"
|
|
||||||
|
|
||||||
client = _make_client(demo_mode_fn=lambda: False)
|
|
||||||
|
|
||||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
|
||||||
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
|
|
||||||
res = client.post("/api/feedback", json={
|
|
||||||
"title": "Oops", "description": "desc", "type": "other",
|
|
||||||
})
|
|
||||||
|
|
||||||
assert res.status_code == 502
|
|
||||||
Loading…
Reference in a new issue