Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299375e486 |
@@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
cid: string
|
||||||
|
version: string | null
|
||||||
|
document_type: string | null
|
||||||
|
document_id: string | null
|
||||||
|
parent_cid: string | null
|
||||||
|
created_at: string | null
|
||||||
|
checksum: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffResponse {
|
||||||
|
kind: 'text' | 'binary'
|
||||||
|
cid_a: string
|
||||||
|
cid_b: string
|
||||||
|
metadata_diff: Record<string, { old: unknown; new: unknown }>
|
||||||
|
diff?: string
|
||||||
|
added_lines?: number
|
||||||
|
removed_lines?: number
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cid: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function shorten(cid: string): string {
|
||||||
|
if (cid.length <= 14) return cid
|
||||||
|
return cid.slice(0, 8) + '…' + cid.slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||||
|
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||||
|
const [diffLoading, setDiffLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancel = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const json = await r.json()
|
||||||
|
if (!cancel) setHistory(json.history || [])
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancel) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
}, [cid])
|
||||||
|
|
||||||
|
async function loadDiff(a: string, b: string) {
|
||||||
|
setDiffPair({ a, b })
|
||||||
|
setDiff(null)
|
||||||
|
setDiffLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const json = (await res.json()) as DiffResponse
|
||||||
|
setDiff(json)
|
||||||
|
} else {
|
||||||
|
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDiffLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
|
||||||
|
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||||
|
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen…</div>}
|
||||||
|
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && history.length === 0 && (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && history.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||||
|
</div>
|
||||||
|
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||||
|
{history.map((entry, idx) => {
|
||||||
|
const next = history[idx + 1]
|
||||||
|
return (
|
||||||
|
<li key={entry.cid} className="relative">
|
||||||
|
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
|
||||||
|
</div>
|
||||||
|
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
|
||||||
|
</div>
|
||||||
|
{next && (
|
||||||
|
<button
|
||||||
|
onClick={() => loadDiff(next.cid, entry.cid)}
|
||||||
|
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||||
|
title="Aenderungen zur Vorversion anzeigen"
|
||||||
|
>
|
||||||
|
Diff zu V{next.version || '?'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
|
{entry.document_type && <span>Typ: {entry.document_type}</span>}
|
||||||
|
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
|
||||||
|
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
|
||||||
|
</div>
|
||||||
|
{entry.checksum && (
|
||||||
|
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffPair && (
|
||||||
|
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||||
|
Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen…</div>}
|
||||||
|
{!diffLoading && diff && (
|
||||||
|
<>
|
||||||
|
{Object.keys(diff.metadata_diff || {}).length > 0 && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
|
||||||
|
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
|
||||||
|
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
|
||||||
|
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{diff.kind === 'text' && diff.diff && (
|
||||||
|
<>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
|
||||||
|
{diff.diff}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{diff.kind === 'binary' && (
|
||||||
|
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
|
||||||
|
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||||
|
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||||
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||||
|
|
||||||
|
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
||||||
|
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
||||||
|
function extractCID(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed)
|
||||||
|
if (typeof parsed.cid === 'string') return parsed.cid
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuditTimelinePage() {
|
export default function AuditTimelinePage() {
|
||||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||||
|
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<TimelineEntry key={entry.id} entry={entry} />
|
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
||||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||||
const date = new Date(entry.performed_at)
|
const date = new Date(entry.performed_at)
|
||||||
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
|||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||||
)}
|
)}
|
||||||
{isCID && entry.new_value && (
|
{isCID && entry.new_value && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
|||||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
||||||
|
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
||||||
|
>
|
||||||
|
Verlauf anzeigen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,7 +256,8 @@ async def archive_legal_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{cid}/history")
|
@router.get("/api/v1/documents/{cid}/history")
|
||||||
|
@router.get("/documents/{cid}/history") # legacy path, kept for backwards compatibility
|
||||||
async def get_document_history(cid: str):
|
async def get_document_history(cid: str):
|
||||||
"""Follow the parent_cid chain to reconstruct version history."""
|
"""Follow the parent_cid chain to reconstruct version history."""
|
||||||
history = []
|
history = []
|
||||||
@@ -285,3 +286,99 @@ async def get_document_history(cid: str):
|
|||||||
break
|
break
|
||||||
|
|
||||||
return {"cid": cid, "history": history, "depth": len(history)}
|
return {"cid": cid, "history": history, "depth": len(history)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/documents/{cid_a}/diff/{cid_b}")
|
||||||
|
async def diff_documents(cid_a: str, cid_b: str):
|
||||||
|
"""
|
||||||
|
Compare two DSMS document versions by their CIDs.
|
||||||
|
|
||||||
|
Returns a unified diff of the textual content when both documents are
|
||||||
|
text-decodable (UTF-8). For binary documents the response indicates
|
||||||
|
"binary" and returns just the metadata differences. Used by the Audit
|
||||||
|
Timeline UI to render "what changed between V2 and V3 of CE-Akte X".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw_a = await ipfs_cat(cid_a)
|
||||||
|
raw_b = await ipfs_cat(cid_b)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": f"could not fetch one of the CIDs: {exc}", "cid_a": cid_a, "cid_b": cid_b}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkg_a = json.loads(raw_a)
|
||||||
|
pkg_b = json.loads(raw_b)
|
||||||
|
except Exception:
|
||||||
|
# Documents are not the wrapped-package JSON shape — treat as raw.
|
||||||
|
pkg_a = {"metadata": {}, "content_base64": ""}
|
||||||
|
pkg_b = {"metadata": {}, "content_base64": ""}
|
||||||
|
|
||||||
|
meta_a = pkg_a.get("metadata", {}) or {}
|
||||||
|
meta_b = pkg_b.get("metadata", {}) or {}
|
||||||
|
meta_diff = _diff_metadata(meta_a, meta_b)
|
||||||
|
|
||||||
|
# Try to decode the content. The Archive flow stores files as base64 in
|
||||||
|
# `content_base64`; older payloads may use `content` (utf-8 text).
|
||||||
|
text_a, text_b, is_binary = _extract_texts(pkg_a, pkg_b)
|
||||||
|
|
||||||
|
if is_binary:
|
||||||
|
return {
|
||||||
|
"cid_a": cid_a,
|
||||||
|
"cid_b": cid_b,
|
||||||
|
"kind": "binary",
|
||||||
|
"metadata_diff": meta_diff,
|
||||||
|
"note": "Binary payload — text diff omitted. Compare via the rendered tech-file export instead.",
|
||||||
|
}
|
||||||
|
|
||||||
|
diff_lines = list(
|
||||||
|
_unified_diff(text_a.splitlines(), text_b.splitlines(), fromfile=cid_a, tofile=cid_b, lineterm="")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"cid_a": cid_a,
|
||||||
|
"cid_b": cid_b,
|
||||||
|
"kind": "text",
|
||||||
|
"metadata_diff": meta_diff,
|
||||||
|
"diff": "\n".join(diff_lines),
|
||||||
|
"added_lines": sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")),
|
||||||
|
"removed_lines": sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_metadata(a: dict, b: dict) -> dict:
|
||||||
|
"""Return per-field change list: {field: {"old": ..., "new": ...}}."""
|
||||||
|
keys = set(a.keys()) | set(b.keys())
|
||||||
|
changes = {}
|
||||||
|
for k in sorted(keys):
|
||||||
|
if a.get(k) != b.get(k):
|
||||||
|
changes[k] = {"old": a.get(k), "new": b.get(k)}
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_texts(pkg_a: dict, pkg_b: dict) -> tuple[str, str, bool]:
|
||||||
|
"""Return (text_a, text_b, is_binary). Falls back to base64-decode."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def to_text(pkg: dict) -> tuple[str, bool]:
|
||||||
|
if isinstance(pkg.get("content"), str):
|
||||||
|
return pkg["content"], False
|
||||||
|
b64 = pkg.get("content_base64")
|
||||||
|
if not b64:
|
||||||
|
return "", False
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(b64)
|
||||||
|
except Exception:
|
||||||
|
return "", True
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8"), False
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "", True
|
||||||
|
|
||||||
|
text_a, bin_a = to_text(pkg_a)
|
||||||
|
text_b, bin_b = to_text(pkg_b)
|
||||||
|
return text_a, text_b, (bin_a or bin_b)
|
||||||
|
|
||||||
|
|
||||||
|
def _unified_diff(a, b, fromfile, tofile, lineterm):
|
||||||
|
"""Tiny shim around difflib.unified_diff so the function reads cleanly."""
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
return difflib.unified_diff(a, b, fromfile=fromfile, tofile=tofile, lineterm=lineterm, n=2)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Tests for the version-chain diff endpoint added in DSMS Stufe 3.
|
||||||
|
|
||||||
|
Mocks ipfs_cat so the test does not require a running IPFS node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(metadata: dict, content_text: str) -> str:
|
||||||
|
"""Mimic the JSON envelope that routers.documents.ipfs_cat returns."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"metadata": metadata,
|
||||||
|
"content_base64": base64.b64encode(content_text.encode("utf-8")).decode("ascii"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_text_documents_returns_unified_diff():
|
||||||
|
pkg_a = _wrap({"version": "1", "document_type": "ce_techfile"}, "alpha\nbeta\ngamma\n")
|
||||||
|
pkg_b = _wrap({"version": "2", "document_type": "ce_techfile"}, "alpha\nDELTA\ngamma\n")
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return pkg_a if cid == "cidA" else pkg_b
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "text"
|
||||||
|
assert body["cid_a"] == "cidA"
|
||||||
|
assert body["cid_b"] == "cidB"
|
||||||
|
assert body["added_lines"] >= 1
|
||||||
|
assert body["removed_lines"] >= 1
|
||||||
|
assert "DELTA" in body["diff"]
|
||||||
|
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_binary_documents_returns_metadata_only():
|
||||||
|
# Use raw bytes that are not utf-8 decodable
|
||||||
|
invalid_utf8 = b"\xff\xfe\xfd\xfc"
|
||||||
|
pkg_a = json.dumps(
|
||||||
|
{"metadata": {"version": "1"}, "content_base64": base64.b64encode(invalid_utf8).decode()}
|
||||||
|
)
|
||||||
|
pkg_b = json.dumps(
|
||||||
|
{"metadata": {"version": "2"}, "content_base64": base64.b64encode(invalid_utf8 + b"\x00").decode()}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return pkg_a if cid == "cidA" else pkg_b
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "binary"
|
||||||
|
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_handles_fetch_error():
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
raise RuntimeError("not pinned")
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "error" in body
|
||||||
|
assert body["cid_a"] == "cidA"
|
||||||
|
assert body["cid_b"] == "cidB"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_endpoint_follows_parent_chain():
|
||||||
|
"""Sanity check that the existing history endpoint still works after route alias."""
|
||||||
|
|
||||||
|
chain = {
|
||||||
|
"v3": _wrap({"version": "3", "parent_cid": "v2"}, "x"),
|
||||||
|
"v2": _wrap({"version": "2", "parent_cid": "v1"}, "x"),
|
||||||
|
"v1": _wrap({"version": "1", "parent_cid": None}, "x"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return chain[cid]
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/v3/history")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["depth"] == 3
|
||||||
|
assert [h["version"] for h in body["history"]] == ["3", "2", "1"]
|
||||||
Reference in New Issue
Block a user