Files
crm-calls/calls/backend/Calls.Api.Tests/RecordingTests.cs

404 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)]);
_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.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 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"));
}
}