forked from 0xWheatyz/SPARC
Add historical analysis diffing for same-company runs
- Add GET /analyze/{company_name}/diff endpoint with from/to query params
- Add GET /analyze/{company_name}/history endpoint for run selection
- Add database methods get_analysis_by_id and list_company_analyses
- Add frontend HistoryDiff page with run selector and diff visualization
- Add Compare with previous button on Analysis results page
- Add navigation link in Layout sidebar
- Add 11 tests covering helpers, happy-path, and 404 scenarios
Closes leeworks-agents/SPARC#1671
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { analysisApi, exportApi } from '../api/client';
|
import { analysisApi, exportApi } from '../api/client';
|
||||||
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react';
|
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown, History } from 'lucide-react';
|
||||||
import type { CompanyAnalysis } from '../types';
|
import type { CompanyAnalysis } from '../types';
|
||||||
|
|
||||||
export function Analysis() {
|
export function Analysis() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [companyName, setCompanyName] = useState('');
|
const [companyName, setCompanyName] = useState('');
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
||||||
@@ -157,6 +159,13 @@ export function Analysis() {
|
|||||||
<FileText size={14} />
|
<FileText size={14} />
|
||||||
Export PDF
|
Export PDF
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/history-diff?company=${encodeURIComponent(result.company_name)}`)}
|
||||||
|
className="flex items-center gap-2 text-sm bg-secondary/20 hover:bg-secondary/30 text-secondary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<History size={14} />
|
||||||
|
Compare with previous
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { analysisApi } from '../api/client';
|
||||||
|
import type { AnalysisHistoryItem, AnalysisDiff } from '../api/client';
|
||||||
|
import { History, ArrowRight, Plus, Minus, AlertCircle, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export function HistoryDiff() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [companyInput, setCompanyInput] = useState(searchParams.get('company') || '');
|
||||||
|
|
||||||
|
const company = searchParams.get('company') || '';
|
||||||
|
const fromId = searchParams.get('from');
|
||||||
|
const toId = searchParams.get('to');
|
||||||
|
|
||||||
|
// Fetch history when a company is selected
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: ['history', company],
|
||||||
|
queryFn: () => analysisApi.getCompanyHistory(company),
|
||||||
|
enabled: !!company,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch diff when both IDs are selected
|
||||||
|
const diffQuery = useQuery<AnalysisDiff>({
|
||||||
|
queryKey: ['diff', company, fromId, toId],
|
||||||
|
queryFn: () => analysisApi.diffAnalyses(company, Number(fromId), Number(toId)),
|
||||||
|
enabled: !!company && !!fromId && !!toId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = companyInput.trim();
|
||||||
|
if (name) {
|
||||||
|
setSearchParams({ company: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRuns = (from: number, to: number) => {
|
||||||
|
setSearchParams({ company, from: String(from), to: String(to) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const history: AnalysisHistoryItem[] = historyQuery.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||||
|
Historical Analysis Diff
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary">
|
||||||
|
Compare analysis runs for the same company to see what changed between them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={companyInput}
|
||||||
|
onChange={(e) => setCompanyInput(e.target.value)}
|
||||||
|
placeholder="Enter company name (e.g., nvidia)"
|
||||||
|
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!companyInput.trim()}
|
||||||
|
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<History size={18} />
|
||||||
|
Load History
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* History list */}
|
||||||
|
{company && historyQuery.isLoading && (
|
||||||
|
<div className="text-text-secondary animate-pulse">Loading analysis history...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{company && historyQuery.isError && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Failed to load history. Check the company name and try again.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{company && history.length === 0 && !historyQuery.isLoading && (
|
||||||
|
<div className="text-text-secondary">No analysis history found for "{company}".</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.length >= 2 && (
|
||||||
|
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||||
|
Select Two Runs to Compare
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{history.map((item, idx) => {
|
||||||
|
const next = history[idx + 1];
|
||||||
|
if (!next) return null;
|
||||||
|
const isSelected =
|
||||||
|
fromId === String(next.id) && toId === String(item.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleSelectRuns(next.id, item.id)}
|
||||||
|
className={`w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-primary/15 hover:border-primary/40 hover:bg-primary/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-text-secondary font-mono">
|
||||||
|
#{next.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
{new Date(next.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={14} className="text-primary" />
|
||||||
|
<span className="text-sm text-text-secondary font-mono">
|
||||||
|
#{item.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
{new Date(item.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{item.model && (
|
||||||
|
<span className="ml-auto text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">
|
||||||
|
{item.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diff Results */}
|
||||||
|
{diffQuery.isLoading && (
|
||||||
|
<div className="text-text-secondary animate-pulse">Computing diff...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffQuery.isError && (
|
||||||
|
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Failed to compute diff. One or both analysis IDs may not exist.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffQuery.data && <DiffView diff={diffQuery.data} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffView({ diff }: { diff: AnalysisDiff }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6 space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||||
|
Diff: #{diff.from_id} → #{diff.to_id}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-primary/5 border border-primary/20 rounded-xl p-4">
|
||||||
|
<div className="text-sm font-medium text-text-primary">{diff.summary}</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
|
||||||
|
<span>{new Date(diff.from_timestamp).toLocaleString()}</span>
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
<span>{new Date(diff.to_timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patent count delta */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-text-secondary">Patent mention delta:</span>
|
||||||
|
<span
|
||||||
|
className={`text-lg font-bold ${
|
||||||
|
diff.patent_count_delta > 0
|
||||||
|
? 'text-success'
|
||||||
|
: diff.patent_count_delta < 0
|
||||||
|
? 'text-error'
|
||||||
|
: 'text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{diff.patent_count_delta > 0 ? '+' : ''}
|
||||||
|
{diff.patent_count_delta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Added patents */}
|
||||||
|
{diff.added_patents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-success flex items-center gap-1 mb-2">
|
||||||
|
<Plus size={14} />
|
||||||
|
New Patents ({diff.added_patents.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{diff.added_patents.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="text-xs bg-success/10 border border-success/20 text-success px-2 py-1 rounded font-mono"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Removed patents */}
|
||||||
|
{diff.removed_patents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-error flex items-center gap-1 mb-2">
|
||||||
|
<Minus size={14} />
|
||||||
|
Removed Patents ({diff.removed_patents.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{diff.removed_patents.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="text-xs bg-error/10 border border-error/20 text-error px-2 py-1 rounded font-mono"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Changed fields */}
|
||||||
|
{Object.keys(diff.changed_fields).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-text-primary mb-2">Changed Fields</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(diff.changed_fields).map(([field, vals]) => (
|
||||||
|
<div key={field} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-text-secondary font-mono">{field}:</span>
|
||||||
|
<span className="text-error line-through">{vals.from || 'null'}</span>
|
||||||
|
<ArrowRight size={12} className="text-text-secondary" />
|
||||||
|
<span className="text-success">{vals.to || 'null'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Tests for historical analysis diff endpoint."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from SPARC.api import AnalysisDiffResponse, _compute_analysis_diff, _extract_patent_ids, app
|
||||||
|
from SPARC.auth import UserResponse, get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- helpers ----------
|
||||||
|
|
||||||
|
def _mock_user():
|
||||||
|
"""Return a fake authenticated user for dependency override."""
|
||||||
|
return UserResponse(
|
||||||
|
id=1,
|
||||||
|
email="test@example.com",
|
||||||
|
role="user",
|
||||||
|
created_at=datetime(2025, 1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client():
|
||||||
|
"""TestClient with auth dependency overridden."""
|
||||||
|
app.dependency_overrides[get_current_user] = _mock_user
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
yield client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- unit tests for helpers ----------
|
||||||
|
|
||||||
|
class TestExtractPatentIds:
|
||||||
|
"""Test _extract_patent_ids utility."""
|
||||||
|
|
||||||
|
def test_extracts_standard_ids(self):
|
||||||
|
text = "Patent US-12345678-B2 covers the device. Also see US-9876543-A1."
|
||||||
|
ids = _extract_patent_ids(text)
|
||||||
|
assert "US-12345678-B2" in ids
|
||||||
|
assert "US-9876543-A1" in ids
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
assert _extract_patent_ids("") == set()
|
||||||
|
assert _extract_patent_ids(None) == set() # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeAnalysisDiff:
|
||||||
|
"""Test _compute_analysis_diff logic."""
|
||||||
|
|
||||||
|
def test_identical_analyses(self):
|
||||||
|
rec = {
|
||||||
|
"id": 1,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "Patent US-12345678-B2 is notable.",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
diff = _compute_analysis_diff(rec, dict(rec, id=2, timestamp=datetime(2025, 5, 2)))
|
||||||
|
assert diff.patent_count_delta == 0
|
||||||
|
assert diff.added_patents == []
|
||||||
|
assert diff.removed_patents == []
|
||||||
|
|
||||||
|
def test_added_and_removed_patents(self):
|
||||||
|
from_rec = {
|
||||||
|
"id": 1,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "Patent US-12345678-B2 and US-11111111-A1.",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
to_rec = {
|
||||||
|
"id": 2,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "Patent US-12345678-B2 and US-99999999-B1.",
|
||||||
|
"timestamp": datetime(2025, 5, 2),
|
||||||
|
}
|
||||||
|
diff = _compute_analysis_diff(from_rec, to_rec)
|
||||||
|
assert "US-99999999-B1" in diff.added_patents
|
||||||
|
assert "US-11111111-A1" in diff.removed_patents
|
||||||
|
assert diff.patent_count_delta == 0 # one added, one removed
|
||||||
|
|
||||||
|
def test_model_change_detected(self):
|
||||||
|
from_rec = {
|
||||||
|
"id": 1,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
to_rec = {
|
||||||
|
"id": 2,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "anthropic/claude-3.5-sonnet",
|
||||||
|
"response": "",
|
||||||
|
"timestamp": datetime(2025, 5, 2),
|
||||||
|
}
|
||||||
|
diff = _compute_analysis_diff(from_rec, to_rec)
|
||||||
|
assert "model" in diff.changed_fields
|
||||||
|
assert diff.changed_fields["model"]["from"] == "openai/gpt-4o"
|
||||||
|
assert diff.changed_fields["model"]["to"] == "anthropic/claude-3.5-sonnet"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API endpoint tests ----------
|
||||||
|
|
||||||
|
class TestDiffEndpoint:
|
||||||
|
"""Test GET /analyze/{company_name}/diff."""
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_happy_path(self, mock_get_db, auth_client):
|
||||||
|
"""Diff returns structured response when both IDs exist."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
|
||||||
|
from_rec = {
|
||||||
|
"id": 10,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "Patent US-12345678-B2 found.",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
to_rec = {
|
||||||
|
"id": 20,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "Patent US-12345678-B2 and US-99999999-A1 found.",
|
||||||
|
"timestamp": datetime(2025, 5, 10),
|
||||||
|
}
|
||||||
|
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else to_rec
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["company_name"] == "nvidia"
|
||||||
|
assert data["from_id"] == 10
|
||||||
|
assert data["to_id"] == 20
|
||||||
|
assert "US-99999999-A1" in data["added_patents"]
|
||||||
|
assert data["patent_count_delta"] == 1
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_from_id_not_found(self, mock_get_db, auth_client):
|
||||||
|
"""Returns 404 when 'from' analysis ID doesn't exist."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
db.get_analysis_by_id.return_value = None
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/diff?from=999&to=1000")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "999" in response.json()["detail"]
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_to_id_not_found(self, mock_get_db, auth_client):
|
||||||
|
"""Returns 404 when 'to' analysis ID doesn't exist."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
|
||||||
|
from_rec = {
|
||||||
|
"id": 10,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else None
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/diff?from=10&to=999")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "999" in response.json()["detail"]
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_company_mismatch(self, mock_get_db, auth_client):
|
||||||
|
"""Returns 404 when analysis belongs to a different company."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
|
||||||
|
rec = {
|
||||||
|
"id": 10,
|
||||||
|
"company_name": "intel",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
}
|
||||||
|
db.get_analysis_by_id.return_value = rec
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryEndpoint:
|
||||||
|
"""Test GET /analyze/{company_name}/history."""
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_returns_history_list(self, mock_get_db, auth_client):
|
||||||
|
"""History endpoint returns list of past analysis runs."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
db.list_company_analyses.return_value = [
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "...",
|
||||||
|
"timestamp": datetime(2025, 5, 10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"company_name": "nvidia",
|
||||||
|
"analysis_type": "portfolio",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"response": "...",
|
||||||
|
"timestamp": datetime(2025, 5, 1),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["id"] == 20
|
||||||
|
assert data[1]["id"] == 10
|
||||||
|
|
||||||
|
@patch("SPARC.api._get_job_db")
|
||||||
|
def test_empty_history(self, mock_get_db, auth_client):
|
||||||
|
"""History endpoint returns empty list when no analyses exist."""
|
||||||
|
db = MagicMock()
|
||||||
|
mock_get_db.return_value = db
|
||||||
|
db.list_company_analyses.return_value = []
|
||||||
|
|
||||||
|
response = auth_client.get("/analyze/nvidia/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
Reference in New Issue
Block a user