Refactor case pages into two distinct routes
This commit separates the case detail page into two distinct routes:
`/web/cases/{case_id}` for the main case information and document, and
`/web/cases/{case_id}/recordings` for a dedicated view of audio files
and their transcripts.
This change improves the organization and clarity of the case viewing
experience by segmenting the related but distinct information into their
own UI sections.
This commit is contained in:
@@ -15,6 +15,7 @@ use crate::auth::AuthenticatedWebUser;
|
||||
use crate::case_id::{CaseId, CaseIdPath};
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::routes::case_actions::read_document;
|
||||
use crate::routes::web::{RecordingView, scan_recordings};
|
||||
use crate::transcribe::recovery as transcribe_recovery;
|
||||
|
||||
@@ -145,6 +146,38 @@ struct CaseDetailTemplate {
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "case_page.html")]
|
||||
struct CasePageTemplate {
|
||||
slug: String,
|
||||
case_id: String,
|
||||
case_id_short: String,
|
||||
oneliner: Option<String>,
|
||||
/// Rendered document HTML (already passed through `md_to_html`). `Some`
|
||||
/// iff `document.md` exists at render time — template prioritises this
|
||||
/// over status/analyzing placeholders.
|
||||
document_html: Option<String>,
|
||||
recordings_count: usize,
|
||||
transcribed_count: usize,
|
||||
can_analyze: bool,
|
||||
llm_missing: bool,
|
||||
analyzing: bool,
|
||||
has_document: bool,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "case_recordings.html")]
|
||||
struct CaseRecordingsTemplate {
|
||||
slug: String,
|
||||
case_id: String,
|
||||
case_id_short: String,
|
||||
oneliner: Option<String>,
|
||||
recordings: Vec<RecordingView>,
|
||||
transcribe_busy: bool,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
/// Flags derived from filesystem state + config.
|
||||
/// `can_analyze` covers both first analysis and re-analysis — same precondition
|
||||
/// (all non-failed recordings transcribed, LLM configured, no analysis in
|
||||
@@ -293,6 +326,120 @@ pub async fn handle_case_detail(
|
||||
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
||||
}
|
||||
|
||||
/// GET /web/cases/{case_id}
|
||||
///
|
||||
/// Canonical case page. Renders the document inline if present; otherwise
|
||||
/// shows status (analyzing / placeholder for empty / recordings-summary).
|
||||
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
|
||||
/// recordings sub-page.
|
||||
pub async fn handle_case_page(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(pipeline): State<PipelineState>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
||||
|
||||
let case_dir = locate_case_or_404(
|
||||
&user_root,
|
||||
&case_id,
|
||||
&user.slug,
|
||||
"case page (possible IDOR probe)",
|
||||
)
|
||||
.await?;
|
||||
let case_id_str = case_id.to_string();
|
||||
info!(slug = %user.slug, case_id = %case_id, "case page viewed");
|
||||
|
||||
// Read document first; if it's on disk we display it regardless of what
|
||||
// the `analyzing` flag would otherwise say. Resolves the narrow race
|
||||
// where the worker finishes between flag check and template render.
|
||||
let document_html = read_document(&case_dir)
|
||||
.await
|
||||
.map(|md| crate::analyze::render::md_to_html(&md));
|
||||
|
||||
let recordings = scan_recordings(&case_dir).await;
|
||||
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
|
||||
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
|
||||
|
||||
let recordings_count = recordings.len();
|
||||
let transcribed_count = recordings.iter().filter(|r| r.transcript.is_some()).count();
|
||||
let case_id_short = case_id_str.chars().take(8).collect();
|
||||
let is_admin = user.is_admin();
|
||||
|
||||
CasePageTemplate {
|
||||
slug: user.slug,
|
||||
case_id: case_id_str,
|
||||
case_id_short,
|
||||
oneliner,
|
||||
document_html,
|
||||
recordings_count,
|
||||
transcribed_count,
|
||||
can_analyze: flags.can_analyze,
|
||||
llm_missing: flags.llm_missing,
|
||||
analyzing: flags.analyzing,
|
||||
has_document: flags.has_document,
|
||||
is_admin,
|
||||
}
|
||||
.render()
|
||||
.map(Html)
|
||||
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
||||
}
|
||||
|
||||
/// GET /web/cases/{case_id}/recordings
|
||||
///
|
||||
/// Read-only sub-page showing all `.m4a` + transcript pairs for the case.
|
||||
/// Useful for power-users/admins; not part of the day-to-day workflow.
|
||||
pub async fn handle_case_recordings(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(pipeline): State<PipelineState>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
||||
|
||||
let case_dir = locate_case_or_404(
|
||||
&user_root,
|
||||
&case_id,
|
||||
&user.slug,
|
||||
"case recordings (possible IDOR probe)",
|
||||
)
|
||||
.await?;
|
||||
let case_id_str = case_id.to_string();
|
||||
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
|
||||
|
||||
let recordings = scan_recordings(&case_dir).await;
|
||||
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
|
||||
let case_id_short = case_id_str.chars().take(8).collect();
|
||||
let is_admin = user.is_admin();
|
||||
|
||||
CaseRecordingsTemplate {
|
||||
slug: user.slug,
|
||||
case_id: case_id_str,
|
||||
case_id_short,
|
||||
oneliner,
|
||||
recordings,
|
||||
transcribe_busy: t_busy,
|
||||
is_admin,
|
||||
}
|
||||
.render()
|
||||
.map(Html)
|
||||
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
||||
}
|
||||
|
||||
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
|
||||
/// if the directory does not exist OR is soft-deleted (`.deleted` marker
|
||||
/// present). Both are treated as 404 / IDOR probe by callers.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Fall {{ case_id_short }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.back { color: #4a90e2; text-decoration: none; }
|
||||
.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; }
|
||||
.meta { color: #666; font-family: monospace; font-size: 0.9em; }
|
||||
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
|
||||
.status-badge.open { background: #4a90e2; }
|
||||
.status-badge.done { background: #7ed321; }
|
||||
.actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; }
|
||||
.actions form { margin: 0; display: inline; }
|
||||
.actions button { font-size: 1em; padding: 0.5em 1em; }
|
||||
.actions .analyzing { color: #888; font-style: italic; }
|
||||
.actions .llm-missing { color: #a66; font-style: italic; }
|
||||
.actions .delete-form { margin-left: auto; }
|
||||
.placeholder { color: #888; font-style: italic; margin: 1em 0; }
|
||||
.status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; }
|
||||
.recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; }
|
||||
.recordings-link:hover { text-decoration: underline; }
|
||||
.doc-content { font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; }
|
||||
.doc-content p { margin: 0.8em 0; }
|
||||
.doc-content p:first-child { margin-top: 0; }
|
||||
.doc-content p:last-child { margin-bottom: 0; }
|
||||
.doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; }
|
||||
.copy-btn { padding: 0.5em 1em; font-size: 1em; border: 1px solid #4a90e2; background: white; color: #4a90e2; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
||||
.copy-btn:hover { background: #eaf3fc; }
|
||||
.toolbar { display: flex; gap: 0.6em; margin: 1em 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</header>
|
||||
<h1>Fall {{ case_id_short }} {% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}</h1>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
{% when None %}
|
||||
<div class="oneliner" style="color:#888;font-style:italic">Noch kein Oneliner.</div>
|
||||
{% endmatch %}
|
||||
<div class="meta">{{ case_id }}</div>
|
||||
|
||||
<div class="actions">
|
||||
{% if analyzing %}
|
||||
<span class="analyzing">wird analysiert …</span>
|
||||
{% else if can_analyze %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">{% if has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button></form>
|
||||
{% else if llm_missing && !has_document %}
|
||||
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
|
||||
{% endif %}
|
||||
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit">Entfernen</button></form>
|
||||
</div>
|
||||
|
||||
{% match document_html %}
|
||||
{% when Some with (html) %}
|
||||
<div class="toolbar">
|
||||
<button id="copy-btn" type="button" class="copy-btn" hidden>In Zwischenablage</button>
|
||||
</div>
|
||||
<div id="doc-content" class="doc-content">{{ html|safe }}</div>
|
||||
<script>
|
||||
(() => {
|
||||
const btn = document.getElementById('copy-btn');
|
||||
if (!btn) return;
|
||||
btn.hidden = false;
|
||||
const label = btn.textContent;
|
||||
btn.addEventListener('click', async () => {
|
||||
const text = document.getElementById('doc-content').innerText;
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '-1000px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { ok = document.execCommand('copy'); } catch (_) {}
|
||||
ta.remove();
|
||||
}
|
||||
btn.textContent = ok ? 'Kopiert ✓' : 'Fehlgeschlagen';
|
||||
setTimeout(() => { btn.textContent = label; }, 1500);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% when None %}
|
||||
{% if analyzing %}
|
||||
<div class="placeholder">Wird analysiert …</div>
|
||||
{% else if recordings_count == 0 %}
|
||||
<div class="placeholder">Noch keine Aufnahmen vorhanden.</div>
|
||||
{% else %}
|
||||
<div class="status-panel">
|
||||
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmatch %}
|
||||
|
||||
<div>
|
||||
<a class="recordings-link" href="/web/cases/{{ case_id }}/recordings">→ Aufnahmen anzeigen ({{ recordings_count }})</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Aufnahmen Fall {{ case_id_short }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.back { color: #4a90e2; text-decoration: none; }
|
||||
.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; }
|
||||
.meta { color: #666; font-family: monospace; font-size: 0.9em; }
|
||||
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
|
||||
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
|
||||
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
|
||||
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
|
||||
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
|
||||
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases/{{ case_id }}">← Fall {{ case_id_short }}</a></div>
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</header>
|
||||
<h1>Aufnahmen</h1>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<div class="meta">{{ case_id }}</div>
|
||||
|
||||
<h2>Aufnahmen ({{ recordings.len() }})</h2>
|
||||
{% for rec in recordings %}
|
||||
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||
<div class="recording-head">
|
||||
<span>{{ rec.filename }}</span>
|
||||
<audio controls preload="none">
|
||||
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||
</audio>
|
||||
</div>
|
||||
{% if rec.failed %}
|
||||
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||
{% else %}
|
||||
{% match rec.transcript %}
|
||||
{% when Some with (t) %}
|
||||
{% if t.is_empty() %}
|
||||
<div class="pending">(stille Aufnahme)</div>
|
||||
{% else %}
|
||||
<div class="transcript">{{ t }}</div>
|
||||
{% endif %}
|
||||
{% when None %}
|
||||
{% if transcribe_busy %}
|
||||
<div class="pending">Transkription läuft…</div>
|
||||
{% else %}
|
||||
<div class="pending">Transkription ausstehend.</div>
|
||||
{% endif %}
|
||||
{% endmatch %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -557,39 +557,6 @@ async fn recovery_skips_completed_analysis() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Document view
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_view_reads_document() {
|
||||
let config = config_with_llm(unique_tmp("dv"), "http://unused".into());
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
std::fs::write(case_dir.join("document.md"), "der Inhalt").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/cases/{case_id}/document"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("der Inhalt"), "expected content in body");
|
||||
assert!(body_str.contains("Dokument"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Re-analysis path: same handler, version derived from existing documents
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -827,28 +794,6 @@ async fn recovery_skips_deleted_cases() {
|
||||
assert!(rx.try_recv().is_err(), "deleted case must not be enqueued");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_view_without_document_returns_404() {
|
||||
let config = config_with_llm(unique_tmp("dv2"), "http://unused".into());
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
seed_case(&config.data_path, "dr_a", case_id);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/cases/{case_id}/document"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Admin-only reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -27,80 +27,6 @@ fn test_config() -> Arc<Config> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn body_to_string(response: axum::response::Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_index_empty() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_string(response).await;
|
||||
assert!(body.contains("No cases found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_index_shows_cases() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
// Pre-create cases on disk
|
||||
let case1 = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let case2 = "660e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
let open_case = data_path.join("dr_test").join(case1);
|
||||
std::fs::create_dir_all(&open_case).unwrap();
|
||||
std::fs::write(open_case.join("2026-04-13T10-30-00Z.m4a"), b"fake audio 1").unwrap();
|
||||
|
||||
let done_case = data_path.join("dr_test").join(case2);
|
||||
std::fs::create_dir_all(&done_case).unwrap();
|
||||
std::fs::write(done_case.join("2026-04-12T09-00-00Z.m4a"), b"fake audio 2").unwrap();
|
||||
std::fs::write(
|
||||
done_case.join("2026-04-12T09-00-00Z.transcript.txt"),
|
||||
"Herzkatheter ohne Befund.",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(done_case.join("oneliner.txt"), "Herzkatheter unauffällig").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_string(response).await;
|
||||
|
||||
assert!(body.contains(case1));
|
||||
assert!(body.contains(case2));
|
||||
assert!(body.contains("dr_test"));
|
||||
assert!(body.contains("<audio"));
|
||||
assert!(body.contains("2026-04-13T10-30-00Z.m4a"));
|
||||
// case1 has no transcript → pending placeholder
|
||||
assert!(body.contains("Transcription pending"));
|
||||
// case2 has a transcript → rendered
|
||||
assert!(body.contains("Herzkatheter ohne Befund."));
|
||||
// case2 has an oneliner → rendered exactly once (case1 has none)
|
||||
assert!(body.contains("Herzkatheter unauffällig"));
|
||||
assert_eq!(
|
||||
body.matches("class=\"oneliner\"").count(),
|
||||
1,
|
||||
"expected exactly one oneliner div"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_serves_file() {
|
||||
let config = test_config();
|
||||
|
||||
Reference in New Issue
Block a user