e0e3455281
xAI определяет формат аудио по расширению имени файла в multipart-части, а не по её content-type. Back слал нейтральное filename="voice.dat": wav проходил случайно (узнаваемый заголовок), а webm/opus и mp4 из браузерного MediaRecorder получали 400 «Could not detect audio format» → 502 upstream_error → тост «Распознавание речи временно недоступно». Расширение выводится из mime (параметры codecs отбрасываются); неизвестный тип — .wav. Тесты на маппинг и подстановку в multipart.
153 lines
7.4 KiB
Erlang
153 lines
7.4 KiB
Erlang
%%%-------------------------------------------------------------------
|
|
%%% EUnit: прокси транскрибации голоса (logic_ai_transcribe, Front#76).
|
|
%%% Без реальных HTTP: апстрим подменяется хуком stt_http_post.
|
|
%%%-------------------------------------------------------------------
|
|
-module(logic_ai_transcribe_tests).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
-define(RL_TABLE, eventhub_stt_rl).
|
|
-define(UP, eventhub_stt_upstream).
|
|
-define(XAI_URL, <<"https://stt.test/xai">>).
|
|
|
|
setup() ->
|
|
logic_ai_transcribe:ensure(),
|
|
ets:delete_all_objects(?RL_TABLE),
|
|
ets:delete_all_objects(?UP),
|
|
os:putenv("STT_API_KEY", "test-key"),
|
|
os:putenv("STT_PROVIDER", "xai"),
|
|
os:putenv("STT_URL", binary_to_list(?XAI_URL)),
|
|
application:set_env(eventhub, stt_http_post, fun stub_post/4),
|
|
ok.
|
|
|
|
cleanup(_) ->
|
|
os:unsetenv("STT_API_KEY"),
|
|
os:unsetenv("STT_PROVIDER"),
|
|
os:unsetenv("STT_URL"),
|
|
os:unsetenv("STT_MODEL"),
|
|
application:unset_env(eventhub, stt_http_post),
|
|
ok.
|
|
|
|
logic_ai_transcribe_test_() ->
|
|
{foreach, fun setup/0, fun cleanup/1, [
|
|
{"no key means unavailable", fun test_unavailable/0},
|
|
{"xai adapter posts to configured url", fun test_xai_success/0},
|
|
{"openai adapter uses model field", fun test_openai_adapter/0},
|
|
{"upstream errors mapped", fun test_upstream_errors/0},
|
|
{"per-user rate limit", fun test_user_rate_limit/0},
|
|
{"global upstream throttle", fun test_global_throttle/0},
|
|
{"invalid audio rejected", fun test_invalid_audio/0},
|
|
{"multipart layout", fun test_multipart/0},
|
|
{"file name from content-type", fun test_file_name/0},
|
|
{"multipart carries typed file name", fun test_multipart_file_name/0}
|
|
]}.
|
|
|
|
%% Стаб апстрима: фиксирует запрос и отвечает успешным JSON.
|
|
stub_post(Url, Headers, ContentType, Body) ->
|
|
put(stt_call, {Url, Headers, ContentType, Body}),
|
|
{ok, <<"{\"text\":\"привет\",\"language\":\"ru\"}">>}.
|
|
|
|
test_unavailable() ->
|
|
os:unsetenv("STT_API_KEY"),
|
|
?assertEqual({error, unavailable},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio">>, <<"audio/webm">>)).
|
|
|
|
test_xai_success() ->
|
|
?assertEqual({ok, <<"привет">>, <<"ru">>},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio-bytes">>, <<"audio/webm">>)),
|
|
{Url, Headers, ContentType, Body} = get(stt_call),
|
|
?assertEqual(?XAI_URL, Url),
|
|
?assertEqual([{"authorization", "Bearer test-key"}], Headers),
|
|
?assertMatch("multipart/form-data; boundary=" ++ _, ContentType),
|
|
?assertMatch({_, _}, binary:match(Body, <<"name=\"file\"">>)),
|
|
?assertMatch({_, _}, binary:match(Body, <<"audio-bytes">>)),
|
|
%% Без поля format — язык определяется авто (format=true требует language).
|
|
?assertEqual(nomatch, binary:match(Body, <<"name=\"format\"">>)),
|
|
%% webm обязан уйти с расширением .webm (апстрим определяет формат
|
|
%% по имени файла, voice.dat даёт 400).
|
|
?assertMatch({_, _}, binary:match(Body, <<"filename=\"voice.webm\"">>)).
|
|
|
|
test_openai_adapter() ->
|
|
os:putenv("STT_PROVIDER", "openai"),
|
|
os:putenv("STT_URL", "https://stt.test/openai"),
|
|
os:putenv("STT_MODEL", "whisper-large-v3"),
|
|
?assertEqual({ok, <<"привет">>, <<"ru">>},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio">>, <<"audio/webm">>)),
|
|
{Url, _Headers, _CType, Body} = get(stt_call),
|
|
?assertEqual(<<"https://stt.test/openai">>, Url),
|
|
?assertMatch({_, _}, binary:match(Body, <<"name=\"model\"">>)),
|
|
?assertMatch({_, _}, binary:match(Body, <<"whisper-large-v3">>)).
|
|
|
|
test_upstream_errors() ->
|
|
%% Ошибка транспорта.
|
|
application:set_env(eventhub, stt_http_post,
|
|
fun(_Url, _H, _C, _B) -> {error, nxdomain} end),
|
|
?assertEqual({error, upstream_error},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio">>, <<"audio/webm">>)),
|
|
%% Некорректный JSON от апстрима.
|
|
ets:delete_all_objects(?UP),
|
|
application:set_env(eventhub, stt_http_post,
|
|
fun(_Url, _H, _C, _B) -> {ok, <<"not-json">>} end),
|
|
?assertEqual({error, upstream_error},
|
|
logic_ai_transcribe:transcribe(<<"u2">>, <<"audio">>, <<"audio/webm">>)),
|
|
%% JSON без поля text.
|
|
ets:delete_all_objects(?UP),
|
|
application:set_env(eventhub, stt_http_post,
|
|
fun(_Url, _H, _C, _B) -> {ok, <<"{\"language\":\"ru\"}">>} end),
|
|
?assertEqual({error, upstream_error},
|
|
logic_ai_transcribe:transcribe(<<"u3">>, <<"audio">>, <<"audio/webm">>)).
|
|
|
|
test_user_rate_limit() ->
|
|
Now = erlang:system_time(millisecond),
|
|
ets:insert(?RL_TABLE, {{rl, <<"u-rl">>}, Now, 10}),
|
|
?assertEqual({error, rate_limited},
|
|
logic_ai_transcribe:transcribe(<<"u-rl">>, <<"audio">>, <<"audio/webm">>)),
|
|
%% Другой пользователь не затронут.
|
|
?assertEqual({ok, <<"привет">>, <<"ru">>},
|
|
logic_ai_transcribe:transcribe(<<"u-ok">>, <<"audio">>, <<"audio/webm">>)).
|
|
|
|
test_global_throttle() ->
|
|
ets:insert(?UP, {last, erlang:monotonic_time(millisecond)}),
|
|
?assertEqual({error, rate_limited},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio">>, <<"audio/webm">>)).
|
|
|
|
test_invalid_audio() ->
|
|
?assertEqual({error, invalid_audio},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<>>, <<"audio/webm">>)),
|
|
?assertEqual({error, invalid_audio},
|
|
logic_ai_transcribe:transcribe(<<"u1">>,
|
|
binary:copy(<<"x">>, logic_ai_transcribe:max_bytes() + 1),
|
|
<<"audio/webm">>)).
|
|
|
|
test_multipart() ->
|
|
Body = logic_ai_transcribe:build_multipart(
|
|
[{<<"a">>, <<"1">>}], <<"DATA">>, <<"voice.dat">>, <<"audio/webm">>),
|
|
?assertMatch({_, _}, binary:match(Body, <<"name=\"a\"\r\n\r\n1\r\n">>)),
|
|
?assertMatch({_, _}, binary:match(
|
|
Body, <<"name=\"file\"; filename=\"voice.dat\"">>)),
|
|
?assertMatch({_, _}, binary:match(Body, <<"Content-Type: audio/webm">>)),
|
|
?assertMatch({_, _}, binary:match(Body, <<"DATA">>)),
|
|
?assert(binary:match(Body, <<"--\r\n">>) =/= nomatch),
|
|
%% Завершающий boundary в конце тела: --<B>--\r\n.
|
|
?assertEqual(<<"--\r\n">>, binary:part(Body, byte_size(Body), -4)).
|
|
|
|
%% Расширение имени файла выводится из mime; параметры codecs
|
|
%% отбрасываются (браузер шлёт audio/webm;codecs=opus).
|
|
test_file_name() ->
|
|
?assertEqual(<<"voice.webm">>, logic_ai_transcribe:file_name(<<"audio/webm">>)),
|
|
?assertEqual(<<"voice.webm">>,
|
|
logic_ai_transcribe:file_name(<<"audio/webm;codecs=opus">>)),
|
|
?assertEqual(<<"voice.mp4">>, logic_ai_transcribe:file_name(<<"audio/mp4">>)),
|
|
?assertEqual(<<"voice.wav">>, logic_ai_transcribe:file_name(<<"audio/wav">>)),
|
|
?assertEqual(<<"voice.wav">>, logic_ai_transcribe:file_name(<<"audio/x-wav">>)),
|
|
?assertEqual(<<"voice.mp3">>, logic_ai_transcribe:file_name(<<"audio/mpeg">>)),
|
|
?assertEqual(<<"voice.m4a">>, logic_ai_transcribe:file_name(<<"audio/x-m4a">>)),
|
|
?assertEqual(<<"voice.wav">>,
|
|
logic_ai_transcribe:file_name(<<"application/octet-stream">>)).
|
|
|
|
%% transcribe() подставляет типизированное имя файла в multipart.
|
|
test_multipart_file_name() ->
|
|
?assertEqual({ok, <<"привет">>, <<"ru">>},
|
|
logic_ai_transcribe:transcribe(<<"u1">>, <<"audio-bytes">>, <<"audio/mp4">>)),
|
|
{_Url, _Headers, _CType, Body} = get(stt_call),
|
|
?assertMatch({_, _}, binary:match(Body, <<"filename=\"voice.mp4\"">>)).
|