Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
<a href="#getting-started">Getting Started</a>
</p>

<p align="center">
<a href="https://github.com/SolFoundry/solfoundry/actions"><img src="https://img.shields.io/github/actions/workflow/status/SolFoundry/solfoundry/ci.yml?branch=main&label=Build" alt="Build Status"/></a>
<a href="https://github.com/SolFoundry/solfoundry/graphs/contributors"><img src="https://img.shields.io/github/contributors/SolFoundry/solfoundry?color=green" alt="Contributors"/></a>
<a href="https://github.com/SolFoundry/solfoundry/issues?q=is%3Aissue+is%3Aopen+label%3Abounty"><img src="https://img.shields.io/github/issues/SolFoundry/solfoundry/bounty?color=blue&label=Open%20Bounties" alt="Open Bounties"/></a>
<a href="https://solfoundry.org"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.solfoundry.org%2Fapi%2Fstats%2Fshields%2Fpayouts&label=Paid&color=blueviolet" alt="Total $FNDRY Paid"/></a>
<a href="https://github.com/SolFoundry/solfoundry/blob/main/LICENSE"><img src="https://img.shields.io/github/license/SolFoundry/solfoundry" alt="License"/></a>
<br/>
<a href="https://github.com/SolFoundry/solfoundry/stargazers"><img src="https://img.shields.io/github/stars/SolFoundry/solfoundry?style=social" alt="Stars"/></a>
<a href="https://github.com/SolFoundry/solfoundry/network/members"><img src="https://img.shields.io/github/forks/SolFoundry/solfoundry?style=social" alt="Forks"/></a>
</p>

<p align="center">
<strong>$FNDRY Token (Solana)</strong><br/>
<code>C2TvY8E8B75EF2UP8cTpTp3EDUjTgjWmpaGnT74VBAGS</code><br/>
Expand Down
47 changes: 34 additions & 13 deletions backend/app/api/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Dict, Optional

from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from app.services.bounty_service import _bounty_store
Expand Down Expand Up @@ -137,20 +138,40 @@ def _get_cached_stats() -> dict:
return data


def format_payout_amount(total_paid: int) -> str:
"""Format total paid amount for badges."""
if total_paid >= 1000000:
return f"{total_paid / 1000000:.1f}M".replace(".0M", "M")
elif total_paid >= 1000:
return f"{total_paid / 1000:.1f}k".replace(".0k", "k")
Comment on lines +143 to +146
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

k-range rounding can produce invalid threshold output.

At Line 146, values just below one million can round to "1000k" (for example 999999), which is a unit-boundary inconsistency in the badge message and can misrepresent the amount format.

As per coding guidelines backend/**: Python FastAPI backend. Analyze thoroughly: API contract consistency with spec; Error handling and edge case coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/stats.py` around lines 143 - 146, The k-range can round up to
"1000k" for values like 999,999; update the formatting logic that uses
total_paid and the two branches returning f"{total_paid / 1000000:.1f}M" and
f"{total_paid / 1000:.1f}k" so rounded k-values of 1000 are normalized to the M
format: either (a) compute the k-value first, round it and if rounded_k == 1000
return "1M", or (b) check if total_paid / 1000 >= 1000 before emitting the
k-branch and fall through to the M-branch; apply the same .replace(".0", "")
behavior so "1000k" never appears.

else:
return f"{total_paid:,}"


@router.get("/api/stats", response_model=StatsResponse)
async def get_stats() -> StatsResponse:
"""Get bounty program statistics.

Returns aggregate statistics about the bounty program:
- Total bounties (created, completed, open)
- Total contributors
- Total $FNDRY paid out
- Total PRs reviewed
- Breakdown by tier
- Top contributor

No authentication required - public endpoint.
Cached for 5 minutes.
"""
"""Get bounty program statistics."""
data = _get_cached_stats()
return StatsResponse(**data)


@router.get("/api/stats/shields/payouts", response_class=JSONResponse)
async def get_payouts_shield():
"""Endpoint format specifically for shields.io custom badge endpoints.
Returns the total $FNDRY paid in a format compatible with shields.io JSON endpoint.
"""
try:
data = _get_cached_stats()
total_paid = data.get("total_fndry_paid", 0)
except Exception as e:
logger.error(f"Error generating shield payout stats: {e}")
total_paid = 0

formatted_paid = format_payout_amount(total_paid)

return {
"schemaVersion": 1,
"label": "Paid",
"message": f"{formatted_paid} $FNDRY",
"color": "blueviolet"
}
177 changes: 65 additions & 112 deletions backend/tests/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
- Normal stats response
- Empty state (no bounties, no contributors)
- Cache behavior (returns cached data within TTL)
- Shields.io custom badge endpoints including edge cases
"""

import pytest
from unittest.mock import patch
from fastapi.testclient import TestClient

from app.main import app
Expand All @@ -27,7 +29,6 @@ def clear_stores():

_bounty_store.clear()
_contributor_store.clear()
# Also clear cache
stats_module._cache.clear()
yield
_bounty_store.clear()
Expand All @@ -39,143 +40,95 @@ class TestStatsEndpoint:
"""Test suite for /api/stats endpoint."""

def test_empty_state(self, client, clear_stores):
"""Test response when no bounties or contributors exist."""
response = client.get("/api/stats")

assert response.status_code == 200
data = response.json()
assert data["total_bounties_created"] == 0
assert data["total_bounties_completed"] == 0
assert data["total_bounties_open"] == 0
assert data["total_contributors"] == 0
assert data["total_fndry_paid"] == 0
assert data["total_prs_reviewed"] == 0
assert data["top_contributor"] is None

def test_normal_response(self, client, clear_stores):
"""Test response with bounties and contributors."""
from app.services.bounty_service import _bounty_store
from app.services.contributor_service import _store as _contributor_store
from app.models.bounty import BountyDB
from app.models.contributor import ContributorDB
import uuid

# Create a contributor
contributor_id = str(uuid.uuid4())
contributor = ContributorDB(
id=uuid.UUID(contributor_id),
username="testuser",
total_bounties_completed=5,
_contributor_store[contributor_id] = ContributorDB(
id=uuid.UUID(contributor_id), username="testuser", total_bounties_completed=5
)
_contributor_store[contributor_id] = contributor

# Create bounties
bounty1 = BountyDB(
id="bounty-1",
title="Test Bounty 1",
tier="tier-1",
reward_amount=50000,
status="completed",
submissions=[],

_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test 1", tier="tier-1", reward_amount=50000, status="completed", submissions=[]
)
bounty2 = BountyDB(
id="bounty-2",
title="Test Bounty 2",
tier="tier-2",
reward_amount=75000,
status="open",
submissions=[],
_bounty_store["bounty-2"] = BountyDB(
id="bounty-2", title="Test 2", tier="tier-2", reward_amount=75000, status="open", submissions=[]
)
_bounty_store["bounty-1"] = bounty1
_bounty_store["bounty-2"] = bounty2

response = client.get("/api/stats")

assert response.status_code == 200
data = response.json()
assert data["total_bounties_created"] == 2
assert data["total_bounties_completed"] == 1
assert data["total_bounties_open"] == 1
assert data["total_contributors"] == 1
assert data["total_fndry_paid"] == 50000
assert data["top_contributor"]["username"] == "testuser"
assert data["top_contributor"]["bounties_completed"] == 5
assert data["bounties_by_tier"]["tier-1"]["completed"] == 1
assert data["bounties_by_tier"]["tier-2"]["open"] == 1

def test_cache_behavior(self, client, clear_stores):
"""Test that cache is used within TTL."""
# First request computes fresh
response1 = client.get("/api/stats")
assert response1.status_code == 200

# Check cache was populated
assert "bounty_stats" in stats_module._cache

# Second request should use cache
response2 = client.get("/api/stats")
assert response2.status_code == 200

# Both should have same data
assert response1.json() == response2.json()

def test_no_auth_required(self, client, clear_stores):
"""Test that stats endpoint requires no authentication."""
# Request without any auth headers
response = client.get("/api/stats")

# Should succeed without 401 Unauthorized
def test_shields_payouts_empty(self, client, clear_stores):
response = client.get("/api/stats/shields/payouts")
assert response.status_code == 200
data = response.json()
assert data["message"] == "0 $FNDRY"
assert data["schemaVersion"] == 1
assert data["label"] == "Paid"

def test_tier_breakdown(self, client, clear_stores):
"""Test tier breakdown statistics."""
def test_shields_payouts_small_amounts(self, client, clear_stores):
from app.services.bounty_service import _bounty_store
from app.models.bounty import BountyDB

# Create bounties in different tiers
bounties = [
BountyDB(
id="t1-open",
title="T1 Open",
tier="tier-1",
reward_amount=50000,
status="open",
submissions=[],
),
BountyDB(
id="t1-done",
title="T1 Done",
tier="tier-1",
reward_amount=50000,
status="completed",
submissions=[],
),
BountyDB(
id="t2-open",
title="T2 Open",
tier="tier-2",
reward_amount=75000,
status="open",
submissions=[],
),
BountyDB(
id="t3-done",
title="T3 Done",
tier="tier-3",
reward_amount=100000,
status="completed",
submissions=[],
),
]
for b in bounties:
_bounty_store[b.id] = b
_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test", tier="tier-1", reward_amount=999, status="completed", submissions=[]
)
response = client.get("/api/stats/shields/payouts")
assert response.json()["message"] == "999 $FNDRY"

response = client.get("/api/stats")
data = response.json()
def test_shields_payouts_thousands(self, client, clear_stores):
from app.services.bounty_service import _bounty_store
from app.models.bounty import BountyDB

assert data["bounties_by_tier"]["tier-1"]["open"] == 1
assert data["bounties_by_tier"]["tier-1"]["completed"] == 1
assert data["bounties_by_tier"]["tier-2"]["open"] == 1
assert data["bounties_by_tier"]["tier-2"]["completed"] == 0
assert data["bounties_by_tier"]["tier-3"]["open"] == 0
assert data["bounties_by_tier"]["tier-3"]["completed"] == 1
_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test", tier="tier-1", reward_amount=250000, status="completed", submissions=[]
)
response = client.get("/api/stats/shields/payouts")
assert response.json()["message"] == "250k $FNDRY"

_bounty_store.clear()
stats_module._cache.clear()

_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test", tier="tier-1", reward_amount=1500, status="completed", submissions=[]
)
response = client.get("/api/stats/shields/payouts")
assert response.json()["message"] == "1.5k $FNDRY"

def test_shields_payouts_millions(self, client, clear_stores):
from app.services.bounty_service import _bounty_store
from app.models.bounty import BountyDB

_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test", tier="tier-1", reward_amount=1000000, status="completed", submissions=[]
)
response = client.get("/api/stats/shields/payouts")
assert response.json()["message"] == "1M $FNDRY"

_bounty_store.clear()
stats_module._cache.clear()

_bounty_store["bounty-1"] = BountyDB(
id="bounty-1", title="Test", tier="tier-1", reward_amount=2500000, status="completed", submissions=[]
)
response = client.get("/api/stats/shields/payouts")
assert response.json()["message"] == "2.5M $FNDRY"

def test_shields_payouts_error_handling(self, client, clear_stores):
with patch('app.api.stats._get_cached_stats', side_effect=Exception("Store failed")):
response = client.get("/api/stats/shields/payouts")
assert response.status_code == 200
data = response.json()
assert data["message"] == "0 $FNDRY"
assert data["schemaVersion"] == 1
Loading
Loading