Files
EventHubBack/test/unit/logic_ai_transcribe_tests.erl
T
aleksey 1ef9020c40
CI / test (push) Successful in 7m45s
CI / deploy-ift (push) Successful in 2m53s
CI / e2e-ift (push) Successful in 1m23s
CI / deploy-stage (push) Successful in 2m9s
CI / e2e-stage (push) Successful in 1m50s
Голосовой центр L2: прокси транскрибации POST /v1/ai/transcribe
Back-прокси STT для press-to-talk (канон PRODUCT-AI, голос и тап —
два канала одного движка действий):

- handler_ai_transcribe + logic_ai_transcribe (паттерн logic_geo):
  адаптеры провайдеров xai и openai-совместимый; env STT_PROVIDER,
  STT_URL, STT_API_KEY, STT_MODEL, STT_TIMEOUT_MS, STT_PROXY_URL
- multipart read_file_part в handler_upload_utils (лимит 10 MB)
- ETS rate-limit: 10 req/min на пользователя + глобальный троттлинг
  апстрима; без ключа — 503 unavailable, ошибки апстрима — 502/503
- аудио и транскрипт не персистятся (приват-инвариант канона)
- отдельный httpc-профиль: STT_PROXY_URL применяется только к STT
  (эгресс stt-egress для гео-блокированных провайдеров)
- trails в eventhub_trails.erl, маршрут в eventhub_app.erl,
  docker/.env.example дополнен STT_*

Проверка: eunit модуля 8/8; полный прогон eunit зелёный (1211 тестов),
compile без новых warning'ов.

Refs EventHub/EventHubBack#79
Refs EventHub/EventHubFront#76
2026-08-20 23:56:35 +03:00

130 lines
5.8 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}
]}.
%% Стаб апстрима: фиксирует запрос и отвечает успешным 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=\"format\"">>)),
?assertMatch({_, _}, binary:match(Body, <<"name=\"file\"">>)),
?assertMatch({_, _}, binary:match(Body, <<"audio-bytes">>)),
%% Поле file — последнее (требование xAI).
{PosFormat, _} = binary:match(Body, <<"name=\"format\"">>),
{PosFile, _} = binary:match(Body, <<"name=\"file\"">>),
?assert(PosFormat < PosFile).
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)).