Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-shell/tests/stt_integration.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

510 lines
16 KiB
Rust

use nomifun_api_types::{
DeepgramSpeechToTextConfig, OpenAISpeechToTextConfig, SpeechToTextConfig, SpeechToTextProvider,
};
use nomifun_shell::{SttError, SttService};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn dummy_audio() -> Vec<u8> {
vec![0u8; 64]
}
fn stt_service() -> SttService {
SttService::new(reqwest::Client::new())
}
// ---------------------------------------------------------------------------
// ST-1: OpenAI transcription — success
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st1_openai_transcribe_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/audio/transcriptions"))
.and(header("Authorization", "Bearer sk-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "hello world" })))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: Some(OpenAISpeechToTextConfig {
api_key: "sk-test-key".into(),
base_url: Some(mock_server.uri()),
model: "whisper-1".into(),
language: None,
prompt: None,
temperature: None,
}),
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await
.unwrap();
assert_eq!(result.text, "hello world");
assert_eq!(result.model, "whisper-1");
assert_eq!(result.provider, SpeechToTextProvider::Openai);
}
// ---------------------------------------------------------------------------
// ST-2: Deepgram transcription — success
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st2_deepgram_transcribe_success() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/listen"))
.and(header("Authorization", "Token dg-test-key"))
.and(header("Content-Type", "audio/wav"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"metadata": {
"model_info": {
"uuid-1": {
"name": "2-general-nova",
"version": "2024-01"
}
}
},
"results": {
"channels": [{
"detected_language": "en",
"alternatives": [{
"transcript": "hello deepgram"
}]
}]
}
})))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: Some(DeepgramSpeechToTextConfig {
api_key: "dg-test-key".into(),
base_url: Some(mock_server.uri()),
model: "nova-2".into(),
language: None,
detect_language: Some(true),
punctuate: Some(true),
smart_format: Some(true),
}),
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await
.unwrap();
assert_eq!(result.text, "hello deepgram");
assert_eq!(result.model, "2-general-nova");
assert_eq!(result.provider, SpeechToTextProvider::Deepgram);
assert_eq!(result.language.as_deref(), Some("en"));
}
// ---------------------------------------------------------------------------
// ST-3: STT disabled
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st3_stt_disabled() {
let config = SpeechToTextConfig {
enabled: false,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: None,
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
assert!(matches!(result, Err(SttError::Disabled)));
}
// ---------------------------------------------------------------------------
// ST-4: STT config missing — treated as disabled at service layer
// (the handler reads from ClientPrefService; if key is absent, config
// will have enabled=false or we surface STT_DISABLED upstream)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ST-5: OpenAI missing API key
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st5_openai_empty_api_key() {
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: Some(OpenAISpeechToTextConfig {
api_key: String::new(),
base_url: None,
model: "whisper-1".into(),
language: None,
prompt: None,
temperature: None,
}),
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
}
#[tokio::test]
async fn st5b_openai_config_section_missing() {
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: None,
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
}
// ---------------------------------------------------------------------------
// ST-6: Deepgram missing API key
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st6_deepgram_empty_api_key() {
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: Some(DeepgramSpeechToTextConfig {
api_key: String::new(),
base_url: None,
model: "nova-2".into(),
language: None,
detect_language: None,
punctuate: None,
smart_format: None,
}),
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
}
#[tokio::test]
async fn st6b_deepgram_config_section_missing() {
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
}
// ---------------------------------------------------------------------------
// ST-7: OpenAI upstream API failure (401)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st7_openai_upstream_failure() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/audio/transcriptions"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": "Incorrect API key provided",
"type": "invalid_request_error"
}
})))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: Some(OpenAISpeechToTextConfig {
api_key: "sk-invalid".into(),
base_url: Some(mock_server.uri()),
model: "whisper-1".into(),
language: None,
prompt: None,
temperature: None,
}),
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
match result {
Err(SttError::RequestFailed(msg)) => {
assert!(msg.contains("401"), "expected 401 in error: {msg}");
}
other => panic!("expected RequestFailed, got: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// ST-7b: Deepgram upstream API failure (403)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st7b_deepgram_upstream_failure() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/listen"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ "err_msg": "Invalid credentials" })))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: Some(DeepgramSpeechToTextConfig {
api_key: "dg-invalid".into(),
base_url: Some(mock_server.uri()),
model: "nova-2".into(),
language: None,
detect_language: None,
punctuate: None,
smart_format: None,
}),
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
.await;
match result {
Err(SttError::RequestFailed(msg)) => {
assert!(msg.contains("403"), "expected 403 in error: {msg}");
}
other => panic!("expected RequestFailed, got: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// ST-10: languageHint passed to OpenAI
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st10_openai_language_hint_passed() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/audio/transcriptions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "你好世界" })))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: None,
openai: Some(OpenAISpeechToTextConfig {
api_key: "sk-test".into(),
base_url: Some(mock_server.uri()),
model: "whisper-1".into(),
language: Some("en".into()),
prompt: None,
temperature: None,
}),
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", Some("zh"), &config)
.await
.unwrap();
assert_eq!(result.text, "你好世界");
assert_eq!(result.language.as_deref(), Some("zh"));
}
// ---------------------------------------------------------------------------
// ST-10b: languageHint passed to Deepgram
// ---------------------------------------------------------------------------
#[tokio::test]
async fn st10b_deepgram_language_hint_passed() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/listen"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"metadata": { "model_info": {} },
"results": {
"channels": [{
"detected_language": "zh",
"alternatives": [{ "transcript": "你好" }]
}]
}
})))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: Some(DeepgramSpeechToTextConfig {
api_key: "dg-test".into(),
base_url: Some(mock_server.uri()),
model: "nova-2".into(),
language: None,
detect_language: None,
punctuate: None,
smart_format: None,
}),
};
let result = stt_service()
.transcribe(dummy_audio(), "test.wav", "audio/wav", Some("zh"), &config)
.await
.unwrap();
assert_eq!(result.text, "你好");
assert_eq!(result.language.as_deref(), Some("zh"));
}
// ---------------------------------------------------------------------------
// Additional: OpenAI with all optional params (prompt, temperature)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn openai_with_all_optional_params() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/audio/transcriptions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "technical terms test" })))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Openai,
auto_send: Some(true),
openai: Some(OpenAISpeechToTextConfig {
api_key: "sk-full".into(),
base_url: Some(mock_server.uri()),
model: "whisper-1".into(),
language: Some("en".into()),
prompt: Some("technical terms".into()),
temperature: Some(0.2),
}),
deepgram: None,
};
let result = stt_service()
.transcribe(dummy_audio(), "audio.m4a", "audio/mp4", None, &config)
.await
.unwrap();
assert_eq!(result.text, "technical terms test");
assert_eq!(result.model, "whisper-1");
assert_eq!(result.provider, SpeechToTextProvider::Openai);
assert_eq!(result.language.as_deref(), Some("en"));
}
// ---------------------------------------------------------------------------
// Additional: Deepgram with all optional flags
// ---------------------------------------------------------------------------
#[tokio::test]
async fn deepgram_with_all_optional_flags() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/listen"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"metadata": {
"model_info": {
"id-1": { "name": "nova-2-general" }
}
},
"results": {
"channels": [{
"detected_language": "fr",
"alternatives": [{ "transcript": "bonjour" }]
}]
}
})))
.mount(&mock_server)
.await;
let config = SpeechToTextConfig {
enabled: true,
provider: SpeechToTextProvider::Deepgram,
auto_send: None,
openai: None,
deepgram: Some(DeepgramSpeechToTextConfig {
api_key: "dg-full".into(),
base_url: Some(mock_server.uri()),
model: "nova-2".into(),
language: Some("fr".into()),
detect_language: Some(false),
punctuate: Some(true),
smart_format: Some(true),
}),
};
let result = stt_service()
.transcribe(dummy_audio(), "test.ogg", "audio/ogg", None, &config)
.await
.unwrap();
assert_eq!(result.text, "bonjour");
assert_eq!(result.model, "nova-2-general");
assert_eq!(result.language.as_deref(), Some("fr"));
}
// ---------------------------------------------------------------------------
// SttError → AppError conversion (black-box integration test)
// ---------------------------------------------------------------------------
#[test]
fn stt_error_to_app_error_mapping() {
use nomifun_common::AppError;
let err: AppError = SttError::Disabled.into();
assert!(matches!(err, AppError::BadRequest(_)));
let err: AppError = SttError::OpenaiNotConfigured.into();
assert!(matches!(err, AppError::BadRequest(_)));
let err: AppError = SttError::DeepgramNotConfigured.into();
assert!(matches!(err, AppError::BadRequest(_)));
let err: AppError = SttError::RequestFailed("upstream".into()).into();
assert!(matches!(err, AppError::BadGateway(_)));
let err: AppError = SttError::Unknown("bug".into()).into();
assert!(matches!(err, AppError::Internal(_)));
}