Skip to content
Open
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
1 change: 1 addition & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ class Issue(Base):
location = Column(String, nullable=True)
action_plan = Column(JSONEncodedDict, nullable=True)
integrity_hash = Column(String, nullable=True) # Blockchain integrity seal
previous_integrity_hash = Column(String, nullable=True)

# Voice and Language Support (Issue #291)
submission_type = Column(String, default="text") # 'text', 'voice'
Expand Down
2 changes: 2 additions & 0 deletions backend/requirements-render.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ pydub
googletrans==4.0.2
langdetect
indic-nlp-library
pydantic
python-dotenv
21 changes: 13 additions & 8 deletions backend/routers/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def create_issue(
Issue.latitude <= max_lat,
Issue.longitude >= min_lon,
Issue.longitude <= max_lon
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

Consider adding a comment explaining the rationale for the limit of 100 records in the deduplication query. This helps future maintainers understand why this specific limit was chosen and prevents it from being accidentally removed or changed without consideration. For example: "Limit to 100 records to prevent performance bottlenecks in extremely dense areas while still ensuring adequate deduplication coverage within the 50-meter radius."

Suggested change
Issue.longitude <= max_lon
Issue.longitude <= max_lon
# Limit to 100 records to prevent performance bottlenecks in extremely dense areas
# while still ensuring adequate deduplication coverage within the 50-meter radius.

Copilot uses AI. Check for mistakes.
).all()
).limit(100).all()
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The deduplication query uses limit(100) without an ORDER BY clause. In edge cases with more than 100 open issues in the bounding box, this could miss closer duplicates and return arbitrary records. Consider adding .order_by(Issue.created_at.desc()) or a spatial ordering to ensure the most relevant candidates are checked for deduplication. This would make the behavior more predictable and prioritize recent issues.

Suggested change
).limit(100).all()
).order_by(Issue.created_at.desc()).limit(100).all()

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

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

P2: Limiting the deduplication candidate query to 100 without ordering can skip nearby issues in dense areas, so duplicates may be created even when a matching issue exists within 50m.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/issues.py, line 117:

<comment>Limiting the deduplication candidate query to 100 without ordering can skip nearby issues in dense areas, so duplicates may be created even when a matching issue exists within 50m.</comment>

<file context>
@@ -114,7 +114,7 @@ async def create_issue(
                     Issue.longitude >= min_lon,
                     Issue.longitude <= max_lon
-                ).all()
+                ).limit(100).all()
             )
 
</file context>
Fix with Cubic

)

nearby_issues_with_distance = find_nearby_issues(
Expand Down Expand Up @@ -196,7 +196,8 @@ async def create_issue(
longitude=longitude,
location=location,
action_plan=initial_action_plan,
integrity_hash=integrity_hash
integrity_hash=integrity_hash,
previous_integrity_hash=prev_hash
)

# Offload blocking DB operations to threadpool
Expand Down Expand Up @@ -620,19 +621,23 @@ async def verify_blockchain_integrity(issue_id: int, db: Session = Depends(get_d
# Fetch current issue data
current_issue = await run_in_threadpool(
lambda: db.query(
Issue.id, Issue.description, Issue.category, Issue.integrity_hash
Issue.id, Issue.description, Issue.category, Issue.integrity_hash, Issue.previous_integrity_hash
).filter(Issue.id == issue_id).first()
)

if not current_issue:
raise HTTPException(status_code=404, detail="Issue not found")

# Fetch previous issue's integrity hash to verify the chain
prev_issue_hash = await run_in_threadpool(
lambda: db.query(Issue.integrity_hash).filter(Issue.id < issue_id).order_by(Issue.id.desc()).first()
)

prev_hash = prev_issue_hash[0] if prev_issue_hash and prev_issue_hash[0] else ""
if current_issue.previous_integrity_hash is not None:
# Optimized path: Use stored previous hash
prev_hash = current_issue.previous_integrity_hash
else:
# Legacy path: Fetch from DB
prev_issue_hash = await run_in_threadpool(
lambda: db.query(Issue.integrity_hash).filter(Issue.id < issue_id).order_by(Issue.id.desc()).first()
)
prev_hash = prev_issue_hash[0] if prev_issue_hash and prev_issue_hash[0] else ""

# Recompute hash based on current data and previous hash
# Chaining logic: hash(description|category|prev_hash)
Expand Down
177 changes: 177 additions & 0 deletions backend/tests/test_issues_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import pytest
import sys
import os
import hashlib
from unittest.mock import MagicMock, AsyncMock, patch
from pathlib import Path
from fastapi.testclient import TestClient

# Setup environment
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))

# Mock heavy dependencies before importing main
sys.modules['magic'] = MagicMock()
sys.modules['telegram'] = MagicMock()
sys.modules['telegram.ext'] = MagicMock()
sys.modules['google'] = MagicMock()
sys.modules['google.generativeai'] = MagicMock()
sys.modules['transformers'] = MagicMock()

# Mock torch correctly for issubclass checks in scipy/sklearn
class MockTensor:
pass
mock_torch = MagicMock()
mock_torch.Tensor = MockTensor
sys.modules['torch'] = mock_torch

sys.modules['speech_recognition'] = MagicMock()
sys.modules['googletrans'] = MagicMock()
sys.modules['langdetect'] = MagicMock()
sys.modules['ultralytics'] = MagicMock()
sys.modules['a2wsgi'] = MagicMock()
sys.modules['firebase_functions'] = MagicMock()

# Mock pywebpush
mock_pywebpush = MagicMock()
mock_pywebpush.WebPushException = Exception
sys.modules['pywebpush'] = mock_pywebpush

import backend.main
from backend.main import app
from backend.models import Issue, Base
from backend.database import get_db, engine
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# Use file-based SQLite for testing to ensure thread safety with run_in_threadpool
TEST_DB_FILE = "test_issues.db"
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)

SQLALCHEMY_DATABASE_URL = f"sqlite:///{TEST_DB_FILE}"
test_engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)

# Override dependency
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()

app.dependency_overrides[get_db] = override_get_db
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

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

P2: The test overrides get_db globally but never clears app.dependency_overrides, so later tests in the same session may still use the test DB (or a removed file) and fail unpredictably. Reset the override in the fixture teardown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/tests/test_issues_flow.py, line 67:

<comment>The test overrides `get_db` globally but never clears `app.dependency_overrides`, so later tests in the same session may still use the test DB (or a removed file) and fail unpredictably. Reset the override in the fixture teardown.</comment>

<file context>
@@ -0,0 +1,177 @@
+    finally:
+        db.close()
+
+app.dependency_overrides[get_db] = override_get_db
+
+@pytest.fixture(scope="module")
</file context>
Fix with Cubic


@pytest.fixture(scope="module")
def client():
# Create tables
Base.metadata.create_all(bind=test_engine)

with patch("backend.main.create_all_ai_services") as mock_create:
mock_create.return_value = (AsyncMock(), AsyncMock(), AsyncMock())
# Also patch RAG service retrieve to avoid errors
with patch("backend.routers.issues.rag_service.retrieve", return_value=None):
with TestClient(app) as c:
yield c

# Cleanup
Base.metadata.drop_all(bind=test_engine)
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)

Comment on lines +67 to +85
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clean up app.dependency_overrides after the fixture.

The override is applied globally and never removed, so subsequent tests can unintentionally keep using the test DB dependency.

🧹 Suggested fix
-app.dependency_overrides[get_db] = override_get_db
-
 `@pytest.fixture`(scope="module")
 def client():
+    app.dependency_overrides[get_db] = override_get_db
     # Create tables
     Base.metadata.create_all(bind=test_engine)
@@
-    # Cleanup
+    # Cleanup
+    app.dependency_overrides.pop(get_db, None)
     Base.metadata.drop_all(bind=test_engine)
     if os.path.exists(TEST_DB_FILE):
         os.remove(TEST_DB_FILE)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(scope="module")
def client():
# Create tables
Base.metadata.create_all(bind=test_engine)
with patch("backend.main.create_all_ai_services") as mock_create:
mock_create.return_value = (AsyncMock(), AsyncMock(), AsyncMock())
# Also patch RAG service retrieve to avoid errors
with patch("backend.routers.issues.rag_service.retrieve", return_value=None):
with TestClient(app) as c:
yield c
# Cleanup
Base.metadata.drop_all(bind=test_engine)
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)
`@pytest.fixture`(scope="module")
def client():
app.dependency_overrides[get_db] = override_get_db
# Create tables
Base.metadata.create_all(bind=test_engine)
with patch("backend.main.create_all_ai_services") as mock_create:
mock_create.return_value = (AsyncMock(), AsyncMock(), AsyncMock())
# Also patch RAG service retrieve to avoid errors
with patch("backend.routers.issues.rag_service.retrieve", return_value=None):
with TestClient(app) as c:
yield c
# Cleanup
app.dependency_overrides.pop(get_db, None)
Base.metadata.drop_all(bind=test_engine)
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_issues_flow.py` around lines 67 - 85, The test fixture
`client` sets `app.dependency_overrides[get_db] = override_get_db` but never
removes it; update the `client` fixture to remove that override in the teardown
(after the yield) by deleting or popping `get_db` from
`app.dependency_overrides` (e.g., use `app.dependency_overrides.pop(get_db,
None)`), ensuring this runs before or alongside the existing cleanup that drops
`Base.metadata` and deletes `TEST_DB_FILE`; keep the existing patches
(`backend.main.create_all_ai_services` and
`backend.routers.issues.rag_service.retrieve`) and their context managers
unchanged.

def test_create_issues_and_blockchain_chaining(client):
# 1. Create first issue
data1 = {
"description": "First test issue for blockchain",
"category": "Road",
"latitude": 10.0,
"longitude": 20.0
}
response1 = client.post("/api/issues", data=data1)
assert response1.status_code == 201
issue1_id = response1.json()["id"]

# Verify issue 1 has integrity hash and empty previous hash (or logic handling it)
with TestingSessionLocal() as db:
issue1 = db.query(Issue).filter(Issue.id == issue1_id).first()
assert issue1.integrity_hash is not None
# First issue might have empty prev hash depending on DB state,
# but here DB is fresh so it should be empty string or similar.
expected_prev = ""
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The test verifies that issue1.integrity_hash is computed correctly, but doesn't verify that issue1.previous_integrity_hash is set to the expected value (empty string for the first issue). Consider adding an assertion like assert issue1.previous_integrity_hash == "" to ensure the new column is being populated correctly for the first issue in the chain.

Suggested change
expected_prev = ""
expected_prev = ""
assert issue1.previous_integrity_hash == expected_prev

Copilot uses AI. Check for mistakes.
expected_content = f"{data1['description']}|{data1['category']}|{expected_prev}"
expected_hash = hashlib.sha256(expected_content.encode()).hexdigest()
assert issue1.integrity_hash == expected_hash

# Capture hash for next step
prev_hash_for_2 = issue1.integrity_hash

# 2. Create second issue
data2 = {
"description": "Second test issue for blockchain",
"category": "Water",
"latitude": 10.1, # Different enough to avoid dedupe
"longitude": 20.1
}
response2 = client.post("/api/issues", data=data2)
assert response2.status_code == 201
issue2_id = response2.json()["id"]

# Verify issue 2 links to issue 1
with TestingSessionLocal() as db:
issue2 = db.query(Issue).filter(Issue.id == issue2_id).first()
assert issue2.previous_integrity_hash == prev_hash_for_2

# Verify integrity hash computation
expected_content_2 = f"{data2['description']}|{data2['category']}|{prev_hash_for_2}"
expected_hash_2 = hashlib.sha256(expected_content_2.encode()).hexdigest()
assert issue2.integrity_hash == expected_hash_2

# 3. Call verification endpoint for Issue 2
verify_response = client.get(f"/api/issues/{issue2_id}/blockchain-verify")
assert verify_response.status_code == 200
verify_data = verify_response.json()
assert verify_data["is_valid"] is True
assert verify_data["computed_hash"] == verify_data["current_hash"]
assert "Integrity verified" in verify_data["message"]
Comment on lines +133 to +139
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

Consider adding a test for verifying the blockchain integrity of the first issue (issue1) to ensure the edge case of having an empty previous_integrity_hash is handled correctly by the verification endpoint. This would provide more comprehensive coverage of the blockchain verification logic.

Copilot uses AI. Check for mistakes.

def test_update_issue_status(client):
# Create an issue first
data = {
"description": "Status update test issue",
"category": "Garbage",
"latitude": 11.0,
"longitude": 21.0
}
create_res = client.post("/api/issues", data=data)
issue_id = create_res.json()["id"]

Comment on lines +149 to +151
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert issue creation before reading response payload.

If setup creation fails, this test currently cascades into unclear errors (id lookup on non-201 payload).

✅ Suggested fix
     create_res = client.post("/api/issues", data=data)
+    assert create_res.status_code == 201
     issue_id = create_res.json()["id"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_issues_flow.py` around lines 149 - 151, Add an assertion
to verify the issue creation response succeeded before accessing its JSON
payload: check create_res.status_code (or use create_res.ok) and assert it
equals the expected success code (e.g., 201) with a helpful message, then only
after that line access create_res.json()["id"]; reference the test variable
create_res and the POST to "/api/issues" in backend/tests/test_issues_flow.py.

# Get reference_id
with TestingSessionLocal() as db:
issue = db.query(Issue).filter(Issue.id == issue_id).first()
ref_id = issue.reference_id
initial_status = issue.status
assert initial_status == "open"

# Update status to verified
update_data = {
"reference_id": ref_id,
"status": "verified",
"notes": "Verified by test"
}

# Patch background tasks to avoid sending emails/notifications which might fail without creds
with patch("backend.routers.issues.send_status_notification"):
response = client.put("/api/issues/status", json=update_data)

assert response.status_code == 200
assert response.json()["status"] == "verified"

# Verify DB update
with TestingSessionLocal() as db:
issue = db.query(Issue).filter(Issue.id == issue_id).first()
assert issue.status == "verified"
assert issue.verified_at is not None
22 changes: 22 additions & 0 deletions backend/tests/test_new_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@
sys.modules['telegram'] = mock_telegram
sys.modules['telegram.ext'] = mock_telegram.ext

sys.modules['google'] = MagicMock()
sys.modules['google.generativeai'] = MagicMock()
sys.modules['transformers'] = MagicMock()

class MockTensor:
pass
mock_torch = MagicMock()
mock_torch.Tensor = MockTensor
sys.modules['torch'] = mock_torch

sys.modules['speech_recognition'] = MagicMock()
sys.modules['googletrans'] = MagicMock()
sys.modules['langdetect'] = MagicMock()
sys.modules['ultralytics'] = MagicMock()
sys.modules['a2wsgi'] = MagicMock()
sys.modules['firebase_functions'] = MagicMock()

# Mock pywebpush
mock_pywebpush = MagicMock()
mock_pywebpush.WebPushException = Exception
sys.modules['pywebpush'] = mock_pywebpush

# Import main (will trigger app creation, but lifespan won't run yet)
import backend.main
from backend.main import app
Expand Down