%%%------------------------------------------------------------------- %%% 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 в конце тела: ----\r\n. ?assertEqual(<<"--\r\n">>, binary:part(Body, byte_size(Body), -4)).