418 lines
20 KiB
C#
418 lines
20 KiB
C#
using System.Net;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using Calls.Api.Recording;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Microsoft.Extensions.Time.Testing;
|
||
|
||
namespace Calls.Api.Tests;
|
||
|
||
/// <summary>Поддельный HTTP: обработчик получает запрос и тело, отвечает чем скажут.</summary>
|
||
public sealed class FakeHttp(Func<HttpRequestMessage, string, HttpResponseMessage> handle) : HttpMessageHandler, IHttpClientFactory
|
||
{
|
||
public List<(string Method, string Url, string Body)> Log { get; } = [];
|
||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
|
||
{
|
||
var body = req.Content == null ? "" : await req.Content.ReadAsStringAsync(ct);
|
||
lock (Log) Log.Add((req.Method.Method, req.RequestUri!.ToString(), body));
|
||
return handle(req, body);
|
||
}
|
||
public HttpClient CreateClient(string name) => new(this, disposeHandler: false);
|
||
public static HttpResponseMessage Json(object o, HttpStatusCode code = HttpStatusCode.OK) =>
|
||
new(code) { Content = new StringContent(o as string ?? JsonSerializer.Serialize(o), Encoding.UTF8, "application/json") };
|
||
}
|
||
|
||
public class CallGateTests
|
||
{
|
||
private static (CallGate, FakeTimeProvider) Make(int max = 1)
|
||
{
|
||
var t = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-15T10:00:00Z"));
|
||
return (new CallGate(new RecordingOptions { MaxConcurrentCalls = max }, t), t);
|
||
}
|
||
|
||
[Fact]
|
||
public void Limit1_OtherRoomDenied_SameRoomAllowed()
|
||
{
|
||
var (g, _) = Make();
|
||
g.Update(new() { ["ws-a"] = 2 });
|
||
Assert.True(g.TryEnter("ws-a")); // в идущий звонок — можно
|
||
Assert.False(g.TryEnter("ws-b")); // второй звонок — нет
|
||
Assert.False(g.CanEnter("ws-b"));
|
||
}
|
||
|
||
[Fact]
|
||
public void FreeServer_FirstRoomReservedForTwoMinutes()
|
||
{
|
||
var (g, t) = Make();
|
||
g.Update(new());
|
||
Assert.True(g.TryEnter("ws-a")); // пропуск выдан, человек ещё подключается
|
||
Assert.False(g.TryEnter("ws-b")); // бронь ws-a держит место
|
||
t.Advance(TimeSpan.FromMinutes(3));
|
||
g.Update(new()); // в ws-a так никто и не зашёл
|
||
Assert.True(g.TryEnter("ws-b"));
|
||
}
|
||
|
||
[Fact]
|
||
public void CallEnded_NextRoomAllowed()
|
||
{
|
||
var (g, t) = Make();
|
||
g.Update(new() { ["ws-a"] = 2 });
|
||
Assert.False(g.TryEnter("ws-b"));
|
||
g.Update(new());
|
||
Assert.True(g.TryEnter("ws-b"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Limit2_TwoCallsAllowed()
|
||
{
|
||
var (g, _) = Make(2);
|
||
g.Update(new() { ["ws-a"] = 2 });
|
||
Assert.True(g.TryEnter("ws-b"));
|
||
Assert.False(g.TryEnter("ws-c"));
|
||
}
|
||
|
||
[Fact]
|
||
public void NoFreshProsodyData_DoesNotBlock()
|
||
{
|
||
var (g, t) = Make();
|
||
g.Update(new() { ["ws-a"] = 2 });
|
||
t.Advance(TimeSpan.FromMinutes(1));
|
||
Assert.True(g.TryEnter("ws-b"));
|
||
}
|
||
}
|
||
|
||
public class CallPageTests
|
||
{
|
||
private static readonly TimeZoneInfo Tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Warsaw");
|
||
|
||
[Fact]
|
||
public void Title_InWarsawTime() =>
|
||
Assert.Equal("Дзвінок 2026-10-15 14:05", CallPage.Title(DateTimeOffset.Parse("2026-10-15T12:05:30Z"), Tz));
|
||
|
||
[Fact]
|
||
public void Markdown_HasDurationParticipantsGuestAudioAndTimings()
|
||
{
|
||
var md = CallPage.Markdown(DateTimeOffset.Parse("2026-10-15T12:05:00Z"), 3725,
|
||
[new("Анна", false), new("Іван", true)], "https://s.test/a.mp3",
|
||
new Transcript("uk", [new(3.4, 5, "Добрий день"), new(3661.9, 3663, "До побачення")]), Tz);
|
||
Assert.Contains("15.10.2026 14:05", md);
|
||
Assert.Contains("1 год 2 хв 5 с", md);
|
||
Assert.Contains("Анна, Іван (гість)", md);
|
||
Assert.Contains("[запис дзвінка](https://s.test/a.mp3)", md);
|
||
Assert.Contains("[00:00:03] Добрий день", md);
|
||
Assert.Contains("[01:01:01] До побачення", md);
|
||
}
|
||
}
|
||
|
||
/// <summary>Обработка записи от каталога Jibri до страницы в вики — на поддельных OpenAI, Supabase и вики.</summary>
|
||
public sealed class ProcessorTests : IDisposable
|
||
{
|
||
private readonly string _root = Path.Combine(Path.GetTempPath(), "calls-rec-" + Guid.NewGuid().ToString("N"));
|
||
private readonly FakeTimeProvider _time = new(DateTimeOffset.Parse("2026-10-15T12:00:00Z"));
|
||
private readonly Db _db;
|
||
private readonly CallsRepo _calls;
|
||
public int WikiStatus = 200; // что отвечает вики на создание страницы
|
||
public int StorageStatus = 200;
|
||
public readonly List<string> Pages = [];
|
||
public readonly List<string> Uploads = [];
|
||
public readonly List<string> Folders = [];
|
||
|
||
public ProcessorTests()
|
||
{
|
||
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:DataDir"] = Path.Combine(_root, "db") }).Build();
|
||
_db = new Db(cfg);
|
||
_db.Init();
|
||
_calls = new CallsRepo(_db, _time);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||
try { Directory.Delete(_root, true); } catch { /* временная папка */ }
|
||
}
|
||
|
||
private RecordingOptions Opt(bool keys = true) => new()
|
||
{
|
||
RecordingsDir = Path.Combine(_root, "rec"), MinCallSeconds = 60,
|
||
OpenAiKey = keys ? "sk-test" : "", SupabaseUrl = "https://sb.test", SupabaseKey = "pk",
|
||
CrmUserEmail = keys ? "bot@test" : "", CrmUserPassword = keys ? "pw" : "", Bucket = "files", Prefix = "calls/",
|
||
WikiUrl = "https://wiki.test", WikiFolder = "Дзвінки",
|
||
};
|
||
|
||
private FakeHttp Http() => new((req, body) =>
|
||
{
|
||
var u = req.RequestUri!;
|
||
if (u.Host == "api.openai.com" || u.AbsolutePath.EndsWith("/audio/transcriptions"))
|
||
{
|
||
// в части 0 — сегменты 1–2 с и 50–52 с, в части 1 — 0–3 с (после сдвига должно стать 60–63 с)
|
||
var part = body.Contains("part-001") ? 1 : 0;
|
||
object[] segs = part == 0
|
||
? [new { start = 1.0, end = 2.0, text = " Привіт" }, new { start = 50.0, end = 52.0, text = "Кінець першої" }]
|
||
: [new { start = 0.0, end = 3.0, text = "Друга частина" }];
|
||
return FakeHttp.Json(new { language = "ukrainian", segments = segs });
|
||
}
|
||
if (u.AbsolutePath == "/auth/v1/token") return FakeHttp.Json(new { access_token = "bot-token", expires_in = 3600 });
|
||
if (u.AbsolutePath.StartsWith("/storage/v1/object/files/"))
|
||
{
|
||
if (StorageStatus != 200) return FakeHttp.Json(new { error = "boom" }, (HttpStatusCode)StorageStatus);
|
||
Uploads.Add(Uri.UnescapeDataString(u.AbsolutePath["/storage/v1/object/files/".Length..]));
|
||
return FakeHttp.Json(new { Key = "x" });
|
||
}
|
||
if (u.Host == "wiki.test")
|
||
{
|
||
Assert.Equal("bot-token", req.Headers.Authorization?.Parameter);
|
||
if (u.AbsolutePath == "/api/tree") return FakeHttp.Json(new
|
||
{
|
||
nodes = Folders.Select(f => new { id = "f1", parentId = (string?)null, kind = "folder", title = f }).ToArray(),
|
||
});
|
||
if (u.AbsolutePath == "/api/folders") { Folders.Add("Дзвінки"); return FakeHttp.Json(new { id = "f1" }, HttpStatusCode.Created); }
|
||
if (u.AbsolutePath == "/api/pages")
|
||
{
|
||
if (WikiStatus != 200) return FakeHttp.Json(new { error = "нема" }, (HttpStatusCode)WikiStatus);
|
||
Pages.Add(body);
|
||
return FakeHttp.Json(new { id = "p1" }, HttpStatusCode.Created);
|
||
}
|
||
}
|
||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||
});
|
||
|
||
private RecordingProcessor Proc(FakeHttp http, RecordingOptions o)
|
||
{
|
||
var crm = new CrmSession(http, o);
|
||
return new RecordingProcessor(_calls, o, new Transcriber(http, o), new AudioStorage(http, o, crm), new WikiPublisher(http, o, crm),
|
||
NullLogger<RecordingProcessor>.Instance, _time);
|
||
}
|
||
|
||
/// <summary>Звонок с готовой записью (как после finalize.sh): две части по 60 и 30 с.</summary>
|
||
private (CallRow Call, string Dir) EndedCall(double duration = 90)
|
||
{
|
||
var c = _calls.Create("ws-room", "wsteam0000001");
|
||
_calls.RecordingStarted(c.Id, c.Id);
|
||
_calls.Seen(c.Id, [new("Анна", false), new("Іван", true)]);
|
||
_time.Advance(TimeSpan.FromSeconds(duration)); // звонок шёл duration секунд
|
||
_calls.Seen(c.Id, [new("Анна", false), new("Іван", true)]);
|
||
_calls.Ended(c.Id, _calls.Now, hadRecording: true);
|
||
var dir = Path.Combine(_root, "rec", c.Id);
|
||
Directory.CreateDirectory(Path.Combine(dir, "parts"));
|
||
File.WriteAllBytes(Path.Combine(dir, "audio.mp3"), new byte[1000]);
|
||
File.WriteAllText(Path.Combine(dir, "parts", "part-000.mp3"), "part-000");
|
||
File.WriteAllText(Path.Combine(dir, "parts", "part-001.mp3"), "part-001");
|
||
File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(new
|
||
{
|
||
audio = "audio.mp3", mime = "audio/mpeg", duration, bytes = 1000,
|
||
parts = new[] { new { file = "parts/part-000.mp3", duration = 60.0, bytes = 8 }, new { file = "parts/part-001.mp3", duration = duration - 60, bytes = 8 } },
|
||
}));
|
||
File.WriteAllText(Path.Combine(dir, "ready"), "");
|
||
return (_calls.Get(c.Id)!, dir);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Success_TranscriptOffsets_UploadPage_FilesDeleted()
|
||
{
|
||
var (call, dir) = EndedCall();
|
||
var http = Http();
|
||
await Proc(http, Opt()).RunOnceAsync(default);
|
||
var row = _calls.Get(call.Id)!;
|
||
Assert.Equal("done", row.Status);
|
||
Assert.False(Directory.Exists(dir)); // проверка 3: файлов не осталось
|
||
var up = Assert.Single(Uploads);
|
||
Assert.Matches(@"^calls/2026/10/[0-9a-f]{24}/dzvinok-2026-10-15-1400\.mp3$", up);
|
||
Assert.Contains("1 хв 30 с", JsonDocument.Parse(Pages[0]).RootElement.GetProperty("markdown").GetString());
|
||
Assert.StartsWith("https://sb.test/storage/v1/object/public/files/calls/2026/10/", row.AudioUrl);
|
||
Assert.Equal(["Дзвінки"], Folders); // папку создали
|
||
var page = JsonDocument.Parse(Assert.Single(Pages)).RootElement;
|
||
Assert.Equal("Дзвінок 2026-10-15 14:00", page.GetProperty("title").GetString());
|
||
Assert.Equal("f1", page.GetProperty("parentId").GetString());
|
||
var md = page.GetProperty("markdown").GetString()!;
|
||
Assert.Contains("[00:00:01] Привіт", md);
|
||
Assert.Contains("[00:00:50] Кінець першої", md);
|
||
Assert.Contains("[00:01:00] Друга частина", md); // проверка 4: тайминги второй части сдвинуты на 60 с
|
||
Assert.Contains("Іван (гість)", md);
|
||
Assert.Contains(row.AudioUrl!, md);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ShortCallByPresence_SkippedEvenIfAudioLonger()
|
||
{
|
||
// звонок 50 с, но запись 70 с (хвост после ухода всех) — по звонку он короткий
|
||
var (call, dir) = EndedCall(duration: 50);
|
||
File.WriteAllText(Path.Combine(dir, "meta.json"), File.ReadAllText(Path.Combine(dir, "meta.json")).Replace("\"duration\":50", "\"duration\":70"));
|
||
await Proc(Http(), Opt()).RunOnceAsync(default);
|
||
Assert.Equal("skipped", _calls.Get(call.Id)!.Status);
|
||
Assert.False(Directory.Exists(dir));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ShortCall_Skipped_NoPage_FilesDeleted()
|
||
{
|
||
var (call, dir) = EndedCall(duration: 45);
|
||
var http = Http();
|
||
await Proc(http, Opt()).RunOnceAsync(default);
|
||
Assert.Equal("skipped", _calls.Get(call.Id)!.Status);
|
||
Assert.False(Directory.Exists(dir));
|
||
Assert.Empty(Pages);
|
||
Assert.Empty(http.Log); // наружу ничего не уходило
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NoKeys_Waits_NothingSent_FilesKept()
|
||
{
|
||
var (call, dir) = EndedCall();
|
||
var http = Http();
|
||
await Proc(http, Opt(keys: false)).RunOnceAsync(default);
|
||
var row = _calls.Get(call.Id)!;
|
||
Assert.Equal("waiting", row.Status);
|
||
Assert.Contains("OPENAI_API_KEY", row.Error);
|
||
Assert.True(File.Exists(Path.Combine(dir, "audio.mp3")));
|
||
Assert.Empty(http.Log);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task WikiError_FilesKept_RetryLater_ThenCompletes()
|
||
{
|
||
var (call, dir) = EndedCall();
|
||
var http = Http();
|
||
WikiStatus = 500;
|
||
var p = Proc(http, Opt());
|
||
await p.RunOnceAsync(default);
|
||
var row = _calls.Get(call.Id)!;
|
||
Assert.Equal("error", row.Status);
|
||
Assert.Contains("500", row.Error);
|
||
Assert.True(File.Exists(Path.Combine(dir, "audio.mp3"))); // проверка 6: файлы на месте
|
||
Assert.True(File.Exists(Path.Combine(dir, "transcript.json"))); // транскрипция сохранена
|
||
Assert.NotNull(row.AudioUrl); // аудио уже загружено
|
||
var transcribeCalls = http.Log.Count(l => l.Url.EndsWith("/audio/transcriptions"));
|
||
|
||
await p.RunOnceAsync(default); // пауза ещё не прошла — ничего не делаем
|
||
Assert.Equal("error", _calls.Get(call.Id)!.Status);
|
||
|
||
WikiStatus = 200; // «исправили»
|
||
_time.Advance(TimeSpan.FromMinutes(2));
|
||
await p.RunOnceAsync(default);
|
||
Assert.Equal("done", _calls.Get(call.Id)!.Status);
|
||
Assert.False(Directory.Exists(dir));
|
||
Assert.Single(Uploads); // повтор не грузил аудио второй раз
|
||
Assert.Equal(transcribeCalls, http.Log.Count(l => l.Url.EndsWith("/audio/transcriptions"))); // и не расшифровывал заново
|
||
}
|
||
|
||
[Fact]
|
||
public async Task UploadError_FilesKept()
|
||
{
|
||
var (call, dir) = EndedCall();
|
||
StorageStatus = 500;
|
||
await Proc(Http(), Opt()).RunOnceAsync(default);
|
||
Assert.Equal("error", _calls.Get(call.Id)!.Status);
|
||
Assert.True(Directory.Exists(dir));
|
||
Assert.Empty(Pages);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NoWorkspaceAccess_ClearError_FilesKept()
|
||
{
|
||
var (call, dir) = EndedCall();
|
||
WikiStatus = 403;
|
||
await Proc(Http(), Opt()).RunOnceAsync(default);
|
||
var row = _calls.Get(call.Id)!;
|
||
Assert.Equal("error", row.Status);
|
||
Assert.Contains("не має доступу до воркспейсу wsteam0000001", row.Error);
|
||
Assert.True(Directory.Exists(dir));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task NotReadyYet_Waits_ThenTimesOutWithError()
|
||
{
|
||
var c = _calls.Create("ws-room", "wsteam0000001");
|
||
_calls.RecordingStarted(c.Id, c.Id);
|
||
_calls.Ended(c.Id, _calls.Now, true);
|
||
var p = Proc(Http(), Opt());
|
||
await p.RunOnceAsync(default);
|
||
Assert.Equal("ended", _calls.Get(c.Id)!.Status);
|
||
_time.Advance(TimeSpan.FromMinutes(31));
|
||
await p.RunOnceAsync(default);
|
||
Assert.Equal("error", _calls.Get(c.Id)!.Status);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PartTooBig_Error()
|
||
{
|
||
var (call, _) = EndedCall();
|
||
var o = Opt();
|
||
var big = new RecordingOptions
|
||
{
|
||
RecordingsDir = o.RecordingsDir, MinCallSeconds = 60, OpenAiKey = "k", SupabaseUrl = o.SupabaseUrl, SupabaseKey = "pk",
|
||
CrmUserEmail = "b", CrmUserPassword = "p", WikiUrl = o.WikiUrl, MaxPartBytes = 4,
|
||
};
|
||
await Proc(Http(), big).RunOnceAsync(default);
|
||
Assert.Contains("більша за ліміт", _calls.Get(call.Id)!.Error);
|
||
}
|
||
}
|
||
|
||
/// <summary>Монитор: начало звонка → Jibri startService, пустая комната → stopService и обработка.</summary>
|
||
public sealed class MonitorTests : IDisposable
|
||
{
|
||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "calls-mon-" + Guid.NewGuid().ToString("N"));
|
||
public List<string> Occupants = [];
|
||
public string JibriState = "IDLE";
|
||
|
||
public void Dispose()
|
||
{
|
||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task TwoPeople_StartRecording_Empty_StopAndEnd()
|
||
{
|
||
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||
{
|
||
["Storage:DataDir"] = _dir, ["Jitsi:AppSecret"] = StoreTests.Secret,
|
||
["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789",
|
||
}).Build();
|
||
var db = new Db(cfg); db.Init();
|
||
var opt = new RecordingOptions { EmptyGrace = TimeSpan.Zero, RecorderPassword = "rp" };
|
||
var gate = new CallGate(opt);
|
||
var store = new Store(db, new JitsiToken(cfg), cfg, gate: gate);
|
||
var calls = new CallsRepo(db);
|
||
var http = new FakeHttp((req, body) =>
|
||
{
|
||
var p = req.RequestUri!.AbsolutePath;
|
||
if (p == "/room") return FakeHttp.Json(Occupants.Select(n => new { jid = "x", email = "", display_name = n }).Append(new { jid = "rec", email = "", display_name = "" }).ToArray());
|
||
if (p.EndsWith("/health")) return FakeHttp.Json(new { status = new { busyStatus = JibriState } });
|
||
if (p.EndsWith("/startService")) { JibriState = "BUSY"; return FakeHttp.Json("{}"); }
|
||
if (p.EndsWith("/stopService")) { JibriState = "IDLE"; return FakeHttp.Json("{}"); }
|
||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||
});
|
||
var mon = new CallMonitor(new ProsodyRooms(http, opt), new JibriClient(http, opt), gate, calls, store, opt, NullLogger<CallMonitor>.Instance);
|
||
|
||
// гость получил пропуск — комната попала под наблюдение
|
||
var link = store.GetGuestLink("wsteam0000001", new CrmUser("u1", "a@t", "Анна", 0));
|
||
var gs = store.JoinAsGuest(link.Token, "Іван")!;
|
||
var room = store.IssuePass(gs.Token)!.Room;
|
||
|
||
Occupants = ["Іван"];
|
||
await mon.TickAsync(default);
|
||
Assert.Empty(calls.AllOpen()); // один человек — ещё не звонок
|
||
Assert.False(gate.CanEnter("ws-other")); // но комната занята для лимита
|
||
|
||
Occupants = ["Іван", "Анна"];
|
||
await mon.TickAsync(default);
|
||
var call = Assert.Single(calls.AllOpen());
|
||
Assert.Equal("recording", call.Status);
|
||
var start = Assert.Single(http.Log, l => l.Url.EndsWith("/startService"));
|
||
var sp = JsonDocument.Parse(start.Body).RootElement;
|
||
Assert.Equal(room, sp.GetProperty("callParams").GetProperty("callUrlInfo").GetProperty("callName").GetString());
|
||
Assert.Equal("file", sp.GetProperty("sinkType").GetString());
|
||
Assert.Equal("recorder", sp.GetProperty("callLoginParams").GetProperty("username").GetString());
|
||
|
||
Occupants = [];
|
||
await mon.TickAsync(default);
|
||
Assert.Contains(http.Log, l => l.Url.EndsWith("/stopService"));
|
||
var ended = calls.Get(call.Id)!;
|
||
Assert.Equal("ended", ended.Status);
|
||
Assert.Contains(ended.Participants, p => p.Name == "Іван" && p.Guest);
|
||
Assert.Contains(ended.Participants, p => p.Name == "Анна" && !p.Guest);
|
||
Assert.True(gate.CanEnter("ws-other"));
|
||
}
|
||
}
|