Перевести реализации интерфейсов на явные (§11, вариант A)

Codemod scripts/make_explicit.py: 161 член в 30 прод-файлах конвертирован в
вид "Тип IFoo.Член" (частичные классы и многострочные сигнатуры учтены; Card и
ICard-семейство — DTO, оставлены implicit). Потребители, дёргавшие классы
напрямую, перетипизированы на интерфейсы: 8 мест в проде (самовызовы через
((ISessionClient)this), снят дефолт параметра в явной реализации) и 17 тестовых
файлов (поля, tuple-деконструкции, var/target-typed new). Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 20:16:06 +03:00
parent 355723f4ad
commit 206d61068a
48 changed files with 516 additions and 250 deletions
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод).
Только production-код (/tests/ не трогает). Card (DTO) пропускается.
Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) ->
конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`.
Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout).
Запуск: python scripts/make_explicit.py [--apply] [--file <путь>]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ROOTS = [REPO / "src" / "core", REPO / "src" / "telegram-service", REPO / "src" / "ai-service",
REPO / "src" / "ml-service", REPO / "src" / "storage-service", REPO / "src" / "grpc-hosting"]
# файлы-исключения: DTO/модели, члены которых — собственный публичный API
EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"}
IFACE_DECL = re.compile(
r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M)
CLASS_DECL = re.compile(
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M)
PARTIAL_CLASS = re.compile(
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M)
IFNAME = re.compile(r"\bI[A-Z]\w*")
METHOD_DECL = re.compile(
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)"
r"(?P<ret>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*(?:<[^>]*>)?\s*\(")
PROP_DECL = re.compile(
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
r"virtual\s+|override\s+|sealed\s+|new\s+)*)"
r"(?P<type>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*\{\s*(?P<accessors>(?:public\s+)?(?:get|set|init)[^}]*)\}")
def src_files() -> list[Path]:
out = []
for root in ROOTS:
if not root.exists():
continue
for p in root.rglob("*.cs"):
s = p.as_posix().lower()
if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s:
continue
out.append(p)
return out
def interface_body(text: str, start: int) -> str:
b = text.find("{", start)
if b == -1:
return ""
depth, end = 0, b
for i in range(b, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
end = i
break
return text[b:end]
def norm_types(params: str) -> tuple[str, ...]:
if not params.strip():
return ()
out, depth, cur = [], 0, ""
for ch in params:
if ch in "<([":
depth += 1
elif ch in ">)]":
depth -= 1
if ch == "," and depth == 0:
out.append(cur)
cur = ""
else:
cur += ch
out.append(cur)
res = []
for raw in out:
t = re.sub(r"\s+", " ", raw.strip())
parts = t.rsplit(" ", 1)
if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"):
t = parts[0]
res.append(t.replace("?", "").replace(" ", ""))
return tuple(res)
def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]:
tables: dict[str, dict[str, dict]] = {}
for p in files:
text = p.read_text(encoding="utf-8")
for m in IFACE_DECL.finditer(text):
name = m.group(1)
body = interface_body(text, m.start())
lines = body.splitlines()
i = 0
while i < len(lines):
line = lines[i]
mm = METHOD_DECL.match(line)
if mm and "(" in line:
# сигнатура может быть многострочной (§7: параметры на отдельных строках)
sig_text = line
while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines):
i += 1
sig_text += " " + lines[i].strip()
if sig_text.count("(") > sig_text.count(")"):
i += 1
continue
params = sig_text.split("(", 1)[1].rsplit(")", 1)[0]
tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)}
i += 1
continue
pm = PROP_DECL.match(line)
if pm:
tables.setdefault(name, {})[pm.group("name")] = {
"kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")}
i += 1
return tables
def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]:
result: dict[str, set[str]] = {}
for p in files:
rel = p.relative_to(REPO).as_posix()
if rel in EXCLUDE_FILES:
continue
text = p.read_text(encoding="utf-8")
for m in CLASS_DECL.finditer(text):
ifaces = set(IFNAME.findall(m.group(2)))
if ifaces:
result.setdefault(m.group(1), set()).update(ifaces)
return result
def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]):
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
local: set[str] = set()
for m in PARTIAL_CLASS.finditer(text):
if m.group(1) in cls_ifaces:
local.add(m.group(1))
if not local:
return 0, None, []
allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables}
changed, notes = 0, []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
if stripped.startswith("//") or stripped.startswith("#"):
i += 1
continue
mm = METHOD_DECL.match(line.rstrip("\r\n"))
pm = None if mm else PROP_DECL.match(line.rstrip("\r\n"))
if not mm and not pm:
i += 1
continue
kind = "method" if mm else "prop"
decl = mm or pm
mods = decl.group("mods")
if "static" in mods or "public" not in mods:
i += 1
continue
name = decl.group("name")
if kind == "method":
sig_text, j = line, i
while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines):
j += 1
sig_text += lines[j]
if "(" not in sig_text:
i += 1
continue
sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0])
else:
sig = decl.group("type").replace("?", "").replace(" ", "")
j = i
cands = [ifc for ifc in allowed
if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig]
if not cands:
i += 1
continue
ifc = cands[0]
head = line.rstrip("\r\n")
eol = line[len(head):]
# убрать модификаторы (оставить отступ), затем квалифицировать имя
new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|"
r"override\s+|sealed\s+|new\s+)+", r"\1", head)
m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head)
if not m2:
i += 1
continue
new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():]
lines[i] = new_head + eol
changed += 1
notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}")
i = j + 1
return changed, ("".join(lines) if changed else None), notes
def main() -> int:
apply = "--apply" in sys.argv
only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None
files = src_files()
itables = collect_interfaces(files)
cls_ifaces = collect_class_ifaces(files)
total, per_file = 0, 0
for p in files:
if only and p.as_posix() != only:
continue
changed, new_text, notes = convert_file(p, cls_ifaces, itables)
if not changed:
continue
total += changed
per_file += 1
if apply and new_text is not None:
p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8"))
if not apply:
for n in notes[:3]:
print(" ", n)
print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -53,7 +53,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
{ {
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync( ProviderChatResult result = await client.ChatAsync(
OpenAiConfig(apiKey: "secret-key"), OpenAiConfig(apiKey: "secret-key"),
@@ -87,7 +87,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
{ {
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None); await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
@@ -103,7 +103,7 @@ public sealed class LlmHttpClientTests
const string reasoningOnlyReply = const string reasoningOnlyReply =
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }"""; """{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply)); var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() => LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
@@ -118,7 +118,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
{ {
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply)); var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync( ProviderChatResult result = await client.ChatAsync(
AnthropicConfig(), AnthropicConfig(),
@@ -153,7 +153,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_HttpError_Throws() public async Task ChatAsync_HttpError_Throws()
{ {
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError)); var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() => LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
@@ -171,7 +171,7 @@ public sealed class LlmHttpClientTests
{ {
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"), Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
}); });
LlmHttpClient client = CreateClient(handler); IProviderClient client = CreateClient(handler);
await Assert.ThrowsAsync<LlmHttpException>(() => await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
@@ -186,7 +186,7 @@ public sealed class LlmHttpClientTests
var handler = new StubHttpMessageHandler( var handler = new StubHttpMessageHandler(
StubHttpMessageHandler.JsonOk(OpenAiJsonReply), StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
delay: TestHandlerDelay); delay: TestHandlerDelay);
LlmHttpClient client = new( IProviderClient client = new LlmHttpClient(
TestHttpClient(handler), TestHttpClient(handler),
TestCallTimeout, TestCallTimeout,
TestCallTimeout); TestCallTimeout);
+1 -1
View File
@@ -65,7 +65,7 @@ public sealed class LlmHttpClient : IProviderClient
/// <param name="systemPrompt">Системный промпт.</param> /// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param> /// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns> /// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
public async Task<ProviderChatResult> ChatAsync( async Task<ProviderChatResult> IProviderClient.ChatAsync(
LlmConfig config, LlmConfig config,
string systemPrompt, string systemPrompt,
string userText, string userText,
@@ -17,8 +17,8 @@ public sealed class TenantContext : ITenantContext
public string? SchemaName => Current.Value?.SchemaName; public string? SchemaName => Current.Value?.SchemaName;
/// <inheritdoc /> /// <inheritdoc />
public void SetTenant(TenantId tenantId) => Current.Value = tenantId; void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
/// <inheritdoc /> /// <inheritdoc />
public void Reset() => Current.Value = null; void ITenantContext.Reset() => Current.Value = null;
} }
@@ -79,7 +79,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct) async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -55,7 +55,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
{ {
@@ -68,7 +68,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
if (await IsPaidAllowedAsync(ct)) if (await IsPaidAllowedAsync(ct))
{ {
@@ -51,7 +51,7 @@ public sealed class BudgetedAiTools : IAiTools
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
BudgetStateDto state = await GateStateAsync(ct); BudgetStateDto state = await GateStateAsync(ct);
if (state.Allowed) if (state.Allowed)
@@ -70,7 +70,7 @@ public sealed class BudgetedAiTools : IAiTools
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
IReadOnlyCollection<string> keywords, IReadOnlyCollection<string> keywords,
@@ -48,7 +48,7 @@ public sealed class CbrRateSource : IRatesSource
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct) async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
{ {
try try
{ {
@@ -71,7 +71,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct) async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -107,7 +107,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct) async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct); string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
@@ -71,7 +71,7 @@ public sealed class GrpcAiTools : IAiTools
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct) async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -105,7 +105,7 @@ public sealed class GrpcAiTools : IAiTools
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync( async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
IReadOnlyCollection<string> keywords, IReadOnlyCollection<string> keywords,
@@ -98,7 +98,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct); MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
@@ -123,7 +123,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct) async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -144,7 +144,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct) async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
ResetReply reply; ResetReply reply;
@@ -172,7 +172,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task PushAsync( async Task IMlClient.PushAsync(
string text, string text,
string label, string label,
double delta, double delta,
@@ -182,7 +182,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct) async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
var request = new TrainBatchRequest(); var request = new TrainBatchRequest();
@@ -59,7 +59,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct) async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -82,7 +82,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramAuthResultDto> StartPhoneAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
string apiHash, string apiHash,
@@ -105,7 +105,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramAuthResultDto> StartQrAsync( async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
CancellationToken ct) CancellationToken ct)
@@ -129,7 +129,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> SendCodeAsync(string code, CancellationToken ct) async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -147,7 +147,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> SendPasswordAsync(string password, CancellationToken ct) async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -165,7 +165,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LogoutAsync(CancellationToken ct) async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -181,7 +181,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct) async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -198,7 +198,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetMonitorAsync( async Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
CancellationToken ct) CancellationToken ct)
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -235,7 +235,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> BackfillAsync( async Task<int> ITelegramGateway.BackfillAsync(
string dialogId, string dialogId,
bool force, bool force,
CancellationToken ct) CancellationToken ct)
@@ -256,7 +256,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync( async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
@@ -279,7 +279,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramSourceContentDto> ReadSourceAsync( async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
CancellationToken ct) CancellationToken ct)
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
@@ -324,7 +324,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct) async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -350,7 +350,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramEvalReadDto> ReadForEvalAsync( async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
@@ -381,7 +381,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken ct) async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -398,7 +398,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken ct) async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -13,11 +13,11 @@ public sealed class LocalAiTools : IAiTools
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false)."; "ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc /> /// <inheritdoc />
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct) Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage); => throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc /> /// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync( Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text, string text,
string description, string description,
IReadOnlyCollection<string> keywords, IReadOnlyCollection<string> keywords,
@@ -12,13 +12,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct) Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{ {
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null)); return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone, string phone,
int apiId, int apiId,
string apiHash, string apiHash,
@@ -26,7 +26,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync( Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId, int apiId,
string apiHash, string apiHash,
CancellationToken ct) CancellationToken ct)
@@ -39,20 +39,20 @@ public sealed class LocalTelegramGateway : ITelegramGateway
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase); public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc /> /// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct) Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
public Task SetMonitorAsync( Task ITelegramGateway.SetMonitorAsync(
string dialogId, string dialogId,
bool enabled, bool enabled,
CancellationToken ct) => Task.CompletedTask; CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task<int> BackfillAsync( public Task<int> BackfillAsync(
@@ -61,40 +61,40 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct) => Task.FromResult(0); CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync( Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync( Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId, string dialogId,
long msgId, long msgId,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null)); => Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query, string query,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct) Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false)); => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync( Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); => Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc /> /// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask; Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
} }
@@ -32,7 +32,7 @@ public sealed class LocalFileStorage : IFileStorage
public override string ToString() => $"LocalFileStorage (root: {_rootPath})"; public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
string contentType, string contentType,
@@ -56,7 +56,7 @@ public sealed class LocalFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct) Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
if (!File.Exists(path)) if (!File.Exists(path))
@@ -69,7 +69,7 @@ public sealed class LocalFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct) Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
if (!File.Exists(path)) if (!File.Exists(path))
@@ -82,7 +82,7 @@ public sealed class LocalFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public Task DeleteAsync(string objectKey, CancellationToken ct) Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
string path = ResolvePath(objectKey); string path = ResolvePath(objectKey);
if (File.Exists(path)) if (File.Exists(path))
@@ -67,7 +67,7 @@ public sealed class MinioFileStorage : IFileStorage
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> PutAsync( async Task<string> IFileStorage.PutAsync(
string objectKey, string objectKey,
Stream content, Stream content,
string contentType, string contentType,
@@ -99,7 +99,7 @@ public sealed class MinioFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Stream?> GetAsync(string objectKey, CancellationToken ct) async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{ {
MemoryStream buffer = new(); MemoryStream buffer = new();
try try
@@ -129,7 +129,7 @@ public sealed class MinioFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct) async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{ {
try try
{ {
@@ -145,7 +145,7 @@ public sealed class MinioFileStorage : IFileStorage
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteAsync(string objectKey, CancellationToken ct) async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{ {
try try
{ {
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task UpsertBlacklistAsync( async Task IDiscoveryStore.UpsertBlacklistAsync(
string dialogId, string dialogId,
string name, string name,
string reason, string reason,
@@ -38,13 +39,13 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) async Task IDiscoveryStore.RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{ {
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct); await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct) async Task<DiscoveryBlacklistDto?> IDiscoveryStore.GetBlacklistAsync(string dialogId, CancellationToken ct)
{ {
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking() .AsNoTracking()
@@ -53,7 +54,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct) async Task<IReadOnlyList<DiscoveryBlacklistDto>> IDiscoveryStore.ListBlacklistAsync(CancellationToken ct)
{ {
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
.AsNoTracking() .AsNoTracking()
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync( async Task<IReadOnlyList<DiscoveryCandidateDto>> IDiscoveryStore.ListCandidatesAsync(
string taskId, string taskId,
string? status, string? status,
CancellationToken ct) CancellationToken ct)
@@ -26,7 +27,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct) async Task<DiscoveryCandidateDto?> IDiscoveryStore.GetCandidateAsync(string dialogId, CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking() .AsNoTracking()
@@ -35,19 +36,19 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct) async Task<bool> IDiscoveryStore.IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{ {
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct); return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct) Task<bool> IDiscoveryStore.IsBlacklistedAsync(string dialogId, CancellationToken ct)
{ {
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct); return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) async Task IDiscoveryStore.CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscCandidates.Add(new DiscCandidateEntity _dbContext.DiscCandidates.Add(new DiscCandidateEntity
@@ -66,13 +67,13 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) async Task IDiscoveryStore.DeleteCandidateAsync(string dialogId, CancellationToken ct)
{ {
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct); await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchCandidateAsync( async Task<bool> IDiscoveryStore.PatchCandidateAsync(
string dialogId, string dialogId,
DiscoveryCandidatePatch patch, DiscoveryCandidatePatch patch,
CancellationToken ct) CancellationToken ct)
@@ -91,7 +92,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetCandidateStatusAsync( async Task<bool> IDiscoveryStore.SetCandidateStatusAsync(
string dialogId, string dialogId,
string status, string status,
CancellationToken ct) CancellationToken ct)
@@ -110,7 +111,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetCandidateJoinedAsync( async Task<bool> IDiscoveryStore.SetCandidateJoinedAsync(
string dialogId, string dialogId,
bool autoJoined, bool autoJoined,
CancellationToken ct) CancellationToken ct)
@@ -130,7 +131,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) async Task<int?> IDiscoveryStore.IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -146,7 +147,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct) async Task<bool> IDiscoveryStore.SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task AddLogAsync( async Task IDiscoveryStore.AddLogAsync(
string logId, string logId,
string taskId, string taskId,
string logEvent, string logEvent,
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> CountLogEventAsync( async Task<int> IDiscoveryStore.CountLogEventAsync(
string logEvent, string logEvent,
DateTimeOffset sinceUtc, DateTimeOffset sinceUtc,
CancellationToken ct) CancellationToken ct)
@@ -38,7 +39,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync( async Task<IReadOnlyList<DiscoveryLogDto>> IDiscoveryStore.ListTaskLogAsync(
string taskId, string taskId,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct) async Task<IReadOnlyList<DiscoveryTaskDto>> IDiscoveryStore.ListTasksAsync(CancellationToken ct)
{ {
List<DiscTaskEntity> rows = await _dbContext.DiscTasks List<DiscTaskEntity> rows = await _dbContext.DiscTasks
.AsNoTracking() .AsNoTracking()
@@ -20,7 +21,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct) async Task<DiscoveryTaskDto?> IDiscoveryStore.GetTaskAsync(string taskId, CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking() .AsNoTracking()
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) async Task IDiscoveryStore.CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscTasks.Add(new DiscTaskEntity _dbContext.DiscTasks.Add(new DiscTaskEntity
@@ -52,7 +53,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchTaskAsync( async Task<bool> IDiscoveryStore.PatchTaskAsync(
string taskId, string taskId,
DiscoveryTaskPatch patch, DiscoveryTaskPatch patch,
CancellationToken ct) CancellationToken ct)
@@ -71,7 +72,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct) async Task<bool> IDiscoveryStore.DeleteTaskAsync(string taskId, CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -88,7 +89,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetTaskRunningAsync( async Task<bool> IDiscoveryStore.SetTaskRunningAsync(
string taskId, string taskId,
bool resetProgress, bool resetProgress,
CancellationToken ct) CancellationToken ct)
@@ -117,7 +118,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct) async Task<bool> IDiscoveryStore.SetTaskPausedAsync(string taskId, CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -133,7 +134,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct) async Task<bool> IDiscoveryStore.SetTaskDoneAsync(string taskId, CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -149,7 +150,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> BumpTaskCounterAsync( async Task<bool> IDiscoveryStore.BumpTaskCounterAsync(
string taskId, string taskId,
DiscoveryCounterField field, DiscoveryCounterField field,
int n, int n,
@@ -186,7 +187,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AdvanceSearchAsync( async Task<bool> IDiscoveryStore.AdvanceSearchAsync(
string taskId, string taskId,
int nextIndex, int nextIndex,
bool searchDone, bool searchDone,
@@ -207,7 +208,7 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{ {
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.Where(task => task.Status != "done" && task.Status != "failed"); .Where(task => task.Status != "done" && task.Status != "failed");
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore public sealed partial class KanbanStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct) async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsAsync(CardsQuery query, CancellationToken ct)
{ {
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking(); IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
if (query.Col is null) if (query.Col is null)
@@ -30,7 +31,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync( async Task<IReadOnlyList<CardDto>> ICardStore.SearchCardsAsync(
string q, string q,
int limit, int limit,
CancellationToken ct) CancellationToken ct)
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct) async Task<CardDto?> ICardStore.GetCardAsync(string cardId, CancellationToken ct)
{ {
CardEntity? entity = await _dbContext.Cards CardEntity? entity = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -76,7 +77,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<CardDto?> GetCardBySourceAsync( async Task<CardDto?> ICardStore.GetCardBySourceAsync(
SourceRef source, SourceRef source,
CancellationToken ct) CancellationToken ct)
{ {
@@ -104,7 +105,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct) async Task ICardStore.AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
{ {
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot). // CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
_dbContext.Cards.Add(ToCardEntity(snapshot)); _dbContext.Cards.Add(ToCardEntity(snapshot));
@@ -131,7 +132,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) async Task ICardStore.UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
{ {
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
if (entity is null) if (entity is null)
@@ -152,7 +153,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) async Task<bool> ICardStore.ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
{ {
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
if (entity is null) if (entity is null)
@@ -182,7 +183,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateSeenAsync( async Task ICardStore.UpdateSeenAsync(
string? cardId, string? cardId,
string? col, string? col,
CancellationToken ct) CancellationToken ct)
@@ -201,7 +202,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteForeverAsync(string cardId, CancellationToken ct) async Task ICardStore.DeleteForeverAsync(string cardId, CancellationToken ct)
{ {
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries await _dbContext.DedupEntries
@@ -214,7 +215,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ClearColAsync(string col, CancellationToken ct) async Task<int> ICardStore.ClearColAsync(string col, CancellationToken ct)
{ {
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
await _dbContext.DedupEntries await _dbContext.DedupEntries
@@ -228,7 +229,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct) async Task<IReadOnlyDictionary<string, CardColumnCountDto>> ICardStore.CountCardsByColAsync(CancellationToken ct)
{ {
var rows = await _dbContext.Cards var rows = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore public sealed partial class KanbanStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct) async Task<IReadOnlyList<CardCommentDto>> ICardStore.ListCommentsAsync(string cardId, CancellationToken ct)
{ {
List<LeadCommentEntity> entities = await _dbContext.LeadComments List<LeadCommentEntity> entities = await _dbContext.LeadComments
.AsNoTracking() .AsNoTracking()
@@ -23,7 +24,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task AddCommentAsync( async Task ICardStore.AddCommentAsync(
string commentId, string commentId,
string cardId, string cardId,
string by, string by,
@@ -42,7 +43,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) async Task ICardStore.AddMoveAsync(CardMoveDto move, CancellationToken ct)
{ {
_dbContext.CardMoves.Add(new CardMoveEntity _dbContext.CardMoves.Add(new CardMoveEntity
{ {
@@ -60,7 +61,7 @@ public sealed partial class KanbanStore
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) async Task<IReadOnlyList<AiMarkupExampleDto>> ICardStore.GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{ {
return await _dbContext.CardMoves return await _dbContext.CardMoves
.AsNoTracking() .AsNoTracking()
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore public sealed partial class KanbanStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct) async Task<IReadOnlyList<ContainerDto>> ICardStore.ListContainersAsync(string? space, CancellationToken ct)
{ {
IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking(); IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking();
if (space is not null) if (space is not null)
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct) async Task<ContainerDto?> ICardStore.GetContainerAsync(string containerId, CancellationToken ct)
{ {
ContainerEntity? entity = await _dbContext.Containers ContainerEntity? entity = await _dbContext.Containers
.AsNoTracking() .AsNoTracking()
@@ -37,14 +38,14 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct) async Task ICardStore.CreateContainerAsync(ContainerDto container, CancellationToken ct)
{ {
_dbContext.Containers.Add(ToContainerEntity(container)); _dbContext.Containers.Add(ToContainerEntity(container));
await _dbContext.SaveChangesAsync(ct); await _dbContext.SaveChangesAsync(ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct) async Task ICardStore.UpdateContainerAsync(ContainerDto container, CancellationToken ct)
{ {
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся // Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
// целиком, CreatedAt не трогаем — одним UPDATE. // целиком, CreatedAt не трогаем — одним UPDATE.
@@ -64,7 +65,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> DeleteContainerAsync(string containerId, CancellationToken ct) async Task<int> ICardStore.DeleteContainerAsync(string containerId, CancellationToken ct)
{ {
// Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер // Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер
// удалён, либо ничего не изменилось. // удалён, либо ничего не изменилось.
@@ -84,7 +85,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task ReorderContainersAsync( async Task ICardStore.ReorderContainersAsync(
string space, string space,
IReadOnlyList<string> containerIds, IReadOnlyList<string> containerIds,
CancellationToken ct) CancellationToken ct)
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore public sealed partial class KanbanStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct) async Task<IReadOnlyList<CardDto>> ICardStore.ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{ {
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking(); IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
if (containerId is not null) if (containerId is not null)
@@ -29,7 +30,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchCardAsync( async Task<bool> ICardStore.PatchCardAsync(
string cardId, string cardId,
CardPatch patch, CardPatch patch,
CancellationToken ct) CancellationToken ct)
@@ -68,7 +69,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AddLinkAsync( async Task<bool> ICardStore.AddLinkAsync(
string cardId, string cardId,
CardLinkDto link, CardLinkDto link,
CancellationToken ct) CancellationToken ct)
@@ -85,7 +86,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> RemoveLinkAsync( async Task<bool> ICardStore.RemoveLinkAsync(
string cardId, string cardId,
string linkId, string linkId,
CancellationToken ct) CancellationToken ct)
@@ -106,7 +107,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AddFileAsync( async Task<bool> ICardStore.AddFileAsync(
string cardId, string cardId,
CardFileDto file, CardFileDto file,
CancellationToken ct) CancellationToken ct)
@@ -123,7 +124,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> RemoveFileAsync( async Task<bool> ICardStore.RemoveFileAsync(
string cardId, string cardId,
string fileId, string fileId,
CancellationToken ct) CancellationToken ct)
@@ -144,7 +145,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> MoveCardStageAsync( async Task<bool> ICardStore.MoveCardStageAsync(
string cardId, string cardId,
string containerId, string containerId,
CardHistoryDto historyEntry, CardHistoryDto historyEntry,
@@ -176,7 +177,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetReminderAsync( async Task ICardStore.SetReminderAsync(
string cardId, string cardId,
long atMs, long atMs,
CancellationToken ct) CancellationToken ct)
@@ -191,7 +192,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task ClearReminderAsync(string cardId, CancellationToken ct) async Task ICardStore.ClearReminderAsync(string cardId, CancellationToken ct)
{ {
await _dbContext.Cards await _dbContext.Cards
.Where(card => card.Id == cardId) .Where(card => card.Id == cardId)
@@ -202,7 +203,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct) async Task<int> ICardStore.ClearStageAsync(string containerId, CancellationToken ct)
{ {
return await _dbContext.Cards return await _dbContext.Cards
.Where(card => card.Col == containerId) .Where(card => card.Col == containerId)
@@ -210,7 +211,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) async Task<IReadOnlyList<CardReminderDueDto>> ICardStore.ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{ {
return await _dbContext.Cards return await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -224,7 +225,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct) async Task ICardStore.MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{ {
if (cardIds.Count == 0) if (cardIds.Count == 0)
{ {
@@ -237,7 +238,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct) async Task<int> ICardStore.ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
{ {
return await _dbContext.Cards return await _dbContext.Cards
.Where(card => card.ReminderAt != null && card.ReminderAt <= now) .Where(card => card.ReminderAt != null && card.ReminderAt <= now)
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories; namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore public sealed partial class KanbanStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) async Task<IReadOnlyList<string>> ICardStore.ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{ {
List<string> boardIds = await _dbContext.Containers List<string> boardIds = await _dbContext.Containers
.AsNoTracking() .AsNoTracking()
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ArchiveAsync( async Task<int> ICardStore.ArchiveAsync(
IReadOnlyList<string> cardIds, IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt, DateTimeOffset archivedAt,
CancellationToken ct) CancellationToken ct)
@@ -49,7 +50,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) async Task<IReadOnlyList<string>> ICardStore.ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
{ {
return await _dbContext.Cards return await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) async Task<IReadOnlyList<string>> ICardStore.ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{ {
return await _dbContext.Cards return await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -71,7 +72,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct) async Task<int> ICardStore.PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{ {
if (cardIds.Count == 0) if (cardIds.Count == 0)
{ {
@@ -90,7 +91,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct) async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsForConversionAsync(CancellationToken ct)
{ {
List<CardEntity> entities = await _dbContext.Cards List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -102,7 +103,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateConversionAsync( async Task ICardStore.UpdateConversionAsync(
string cardId, string cardId,
double? convFrom, double? convFrom,
double? convTo, double? convTo,
@@ -119,7 +120,7 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct) async Task<IReadOnlyList<CardDto>> ICardStore.ListInboxWithSourceAsync(CancellationToken ct)
{ {
List<CardEntity> entities = await _dbContext.Cards List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
@@ -59,17 +59,17 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TenantLimitDto> GetOrCreateAsync( async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
Guid tenantId, Guid tenantId,
CancellationToken ct, CancellationToken ct,
TokenLimitDefaults? defaults = null) TokenLimitDefaults? defaults)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
return ToLimitDto(entity); return ToLimitDto(entity);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct) async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
await ResetIfPeriodExpiredAsync(entity, ct); await ResetIfPeriodExpiredAsync(entity, ct);
@@ -77,7 +77,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<BudgetStateDto> AddUsageAsync( async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
Guid tenantId, Guid tenantId,
long tokens, long tokens,
CancellationToken ct) CancellationToken ct)
@@ -110,7 +110,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<BudgetStateDto> UpdateBudgetAsync( async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
Guid tenantId, Guid tenantId,
long budgetTokens, long budgetTokens,
string period, string period,
@@ -133,7 +133,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
await ResetIfPeriodExpiredAsync(entity, ct); await ResetIfPeriodExpiredAsync(entity, ct);
@@ -149,7 +149,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
await ResetIfPeriodExpiredAsync(entity, ct); await ResetIfPeriodExpiredAsync(entity, ct);
@@ -220,7 +220,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{ {
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего. // Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
List<TenantLimitEntity> candidates = await _dbContext.TenantLimits List<TenantLimitEntity> candidates = await _dbContext.TenantLimits
@@ -40,7 +40,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
} }
/// <inheritdoc /> /// <inheritdoc />
public string Encrypt(string plainText) string ISecretCipher.Encrypt(string plainText)
{ {
if (string.IsNullOrEmpty(plainText)) if (string.IsNullOrEmpty(plainText))
{ {
@@ -66,7 +66,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
} }
/// <inheritdoc /> /// <inheritdoc />
public string Decrypt(string cipherText) string ISecretCipher.Decrypt(string cipherText)
{ {
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
{ {
@@ -44,7 +44,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
} }
/// <inheritdoc /> /// <inheritdoc />
public int Next(string taskId) int IDiscoverySearchErrorCounter.Next(string taskId)
{ {
EvictExpired(); EvictExpired();
Entry fresh = _failures.AddOrUpdate( Entry fresh = _failures.AddOrUpdate(
@@ -56,7 +56,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
} }
/// <inheritdoc /> /// <inheritdoc />
public void Reset(string taskId) void IDiscoverySearchErrorCounter.Reset(string taskId)
{ {
EvictExpired(); EvictExpired();
_failures.TryRemove(taskId, out _); _failures.TryRemove(taskId, out _);
@@ -7,6 +7,8 @@ using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -147,7 +149,7 @@ public sealed class BudgetedAiClassifierTests
// Paid: Платный фейк-исполнитель (счётчики/ответы сценария). // Paid: Платный фейк-исполнитель (счётчики/ответы сценария).
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser). // Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
private sealed record Context( private sealed record Context(
BudgetedAiClassifier Decorator, IAiClassifier Decorator,
FakeAiClassifier Paid, FakeAiClassifier Paid,
FakeSettingsStore Settings); FakeSettingsStore Settings);
@@ -160,10 +162,10 @@ public sealed class BudgetedAiClassifierTests
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiClassifier(); var paid = new FakeAiClassifier();
var decorator = new BudgetedAiClassifier( IAiClassifier decorator = new BudgetedAiClassifier(
paid, paid,
new LocalAiClassifier(new LocalFieldsParser(settings)), new LocalAiClassifier(new LocalFieldsParser(settings)),
limits, limits,
@@ -6,6 +6,8 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -128,7 +130,7 @@ public sealed class BudgetedAiToolsTests
// Decorator: Декоратор бюджетного гейта. // Decorator: Декоратор бюджетного гейта.
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария). // Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
private sealed record Context( private sealed record Context(
BudgetedAiTools Decorator, IAiTools Decorator,
FakeAiTools Paid); FakeAiTools Paid);
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует // Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
@@ -139,10 +141,10 @@ public sealed class BudgetedAiToolsTests
{ {
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiTools(); var paid = new FakeAiTools();
var decorator = new BudgetedAiTools( IAiTools decorator = new BudgetedAiTools(
paid, paid,
limits, limits,
tenantContext, tenantContext,
@@ -13,6 +13,8 @@ using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -40,7 +42,7 @@ public sealed class GrpcAiToolsTests
FakeSecretCipher cipher = new(); FakeSecretCipher cipher = new();
FakeTenantLimitStore limits = new(); FakeTenantLimitStore limits = new();
GrpcAiTools tools = CreateTools(port, settings, cipher, limits); IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
Assert.True(result.Ok); Assert.True(result.Ok);
@@ -63,7 +65,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.KeywordsUnavailable = true; service.KeywordsUnavailable = true;
GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -86,7 +88,7 @@ public sealed class GrpcAiToolsTests
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
}; };
FakeTenantLimitStore limits = new(); FakeTenantLimitStore limits = new();
GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits); IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -110,7 +112,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.FitUnavailable = true; service.FitUnavailable = true;
GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher());
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -129,7 +131,7 @@ public sealed class GrpcAiToolsTests
FakeSecretCipher cipher, FakeSecretCipher cipher,
FakeTenantLimitStore? limits = null) FakeTenantLimitStore? limits = null)
{ {
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiTools( return new GrpcAiTools(
@@ -15,6 +15,8 @@ using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -49,7 +51,7 @@ public sealed class GrpcMlClientTests
Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 }, Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 },
}; };
GrpcMlClient client = CreateClient(port); IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None); MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None);
Assert.True(result.Take); Assert.True(result.Take);
@@ -78,7 +80,7 @@ public sealed class GrpcMlClientTests
{ {
service.PredictUnavailable = true; service.PredictUnavailable = true;
GrpcMlClient client = CreateClient(port); IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None); MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None);
Assert.False(result.Take); Assert.False(result.Take);
@@ -113,7 +115,7 @@ public sealed class GrpcMlClientTests
settings.Preload(SettingsKeys.AiDecisions, "3"); settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 }; var learning = new FakeMlLearningStore { LearningCount = 9 };
GrpcMlClient client = CreateClient(port, settings, learning); IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
Assert.True(status.Enabled); // mlEnabled не задан — дефолт true Assert.True(status.Enabled); // mlEnabled не задан — дефолт true
@@ -139,7 +141,7 @@ public sealed class GrpcMlClientTests
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
var cache = new MlStatusCache(() => now); var cache = new MlStatusCache(() => now);
GrpcMlClient client = CreateClient(port, cache: cache); IMlClient client = CreateClient(port, cache: cache);
service.StatusUnavailable = true; service.StatusUnavailable = true;
MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None); MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None);
@@ -173,7 +175,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
GrpcMlClient client = CreateClient(port, cache: new MlStatusCache(() => now)); IMlClient client = CreateClient(port, cache: new MlStatusCache(() => now));
_ = await client.StatusAsync(CancellationToken.None); _ = await client.StatusAsync(CancellationToken.None);
now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис
@@ -196,7 +198,7 @@ public sealed class GrpcMlClientTests
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0); learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
GrpcMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
// Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс. // Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс.
_ = await client.StatusAsync(CancellationToken.None); _ = await client.StatusAsync(CancellationToken.None);
@@ -221,7 +223,7 @@ public sealed class GrpcMlClientTests
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
GrpcMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok); Assert.False(reset.Ok);
@@ -239,7 +241,7 @@ public sealed class GrpcMlClientTests
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0); learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
GrpcMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok); Assert.False(reset.Ok);
@@ -255,7 +257,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
GrpcMlClient client = CreateClient(port, learning: learning); IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
@@ -275,7 +277,7 @@ public sealed class GrpcMlClientTests
{ {
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{ {
GrpcMlClient client = CreateClient(port); IMlClient client = CreateClient(port);
var items = new[] var items = new[]
{ {
new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0), new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0),
@@ -311,7 +313,7 @@ public sealed class GrpcMlClientTests
FakeMlLearningStore? learning = null, FakeMlLearningStore? learning = null,
MlStatusCache? cache = null) MlStatusCache? cache = null)
{ {
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient( return new GrpcMlClient(
@@ -10,6 +10,7 @@ using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -235,7 +236,7 @@ public sealed class GrpcTelegramClientTests
}); });
using (connection) using (connection)
{ {
var gateway = new GrpcTelegramClient( ITelegramGateway gateway = new GrpcTelegramClient(
new TenantContext(), connection, NullLogger<GrpcTelegramClient>.Instance); new TenantContext(), connection, NullLogger<GrpcTelegramClient>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.StatusAsync(CancellationToken.None)); await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.StatusAsync(CancellationToken.None));
} }
@@ -247,7 +248,7 @@ public sealed class GrpcTelegramClientTests
// Возвращает: Гейт (транспорт живёт на время сценария). // Возвращает: Гейт (транспорт живёт на время сценария).
private static GrpcTelegramClient CreateClient(int port) private static GrpcTelegramClient CreateClient(int port)
{ {
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
var connection = new TelegramGrpcConnection(new TelegramServiceOptions var connection = new TelegramGrpcConnection(new TelegramServiceOptions
{ {
@@ -141,7 +141,7 @@ public sealed class IntegrationsDiTests
TelegramServiceOptions telegramOptions) TelegramServiceOptions telegramOptions)
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
@@ -1,4 +1,5 @@
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -7,7 +8,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary> /// </summary>
public sealed class LocalAiToolsTests public sealed class LocalAiToolsTests
{ {
private readonly LocalAiTools _tools = new(); private readonly IAiTools _tools = new LocalAiTools();
[Fact] [Fact]
public async Task GenerateKeywordsAsync_AlwaysThrowsNotSupported() public async Task GenerateKeywordsAsync_AlwaysThrowsNotSupported()
@@ -20,6 +20,7 @@ using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -169,7 +170,7 @@ public sealed class PipelineWorkerGrpcAiTests
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings);
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection( var connection = new AiGrpcConnection(
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
@@ -4,6 +4,7 @@ using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
@@ -18,7 +19,7 @@ public sealed class TenantLimitStoreTests
[Fact] [Fact]
public async Task GetOrCreateAsync_NoRow_CreatesDefaultBudget() public async Task GetOrCreateAsync_NoRow_CreatesDefaultBudget()
{ {
(DealDbContext db, TenantLimitStore store) = CreateStore(); (DealDbContext db, ITenantLimitStore store) = CreateStore();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
@@ -39,7 +40,7 @@ public sealed class TenantLimitStoreTests
[Fact] [Fact]
public async Task GetOrCreateAsync_WithExplicitDefaults_UsesThem() public async Task GetOrCreateAsync_WithExplicitDefaults_UsesThem()
{ {
(DealDbContext db, TenantLimitStore store) = CreateStore(); (DealDbContext db, ITenantLimitStore store) = CreateStore();
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
@@ -54,7 +55,7 @@ public sealed class TenantLimitStoreTests
public async Task GetStateAsync_ReturnsStatusAndAllowed() public async Task GetStateAsync_ReturnsStatusAndAllowed()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(); (DealDbContext db, ITenantLimitStore store) = CreateStore();
// Активный тенант, расход ниже бюджета → Allowed=true. // Активный тенант, расход ниже бюджета → Allowed=true.
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
@@ -75,7 +76,7 @@ public sealed class TenantLimitStoreTests
public async Task AddUsageAsync_WhenPeriodExpired_ResetsUsedAndStartsNewPeriod() public async Task AddUsageAsync_WhenPeriodExpired_ResetsUsedAndStartsNewPeriod()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, periodStart: Now.AddMonths(-2), usedTokens: 700, warned80: true, notifiedExhausted: true); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, periodStart: Now.AddMonths(-2), usedTokens: 700, warned80: true, notifiedExhausted: true);
@@ -93,7 +94,7 @@ public sealed class TenantLimitStoreTests
public async Task AddUsageAsync_WithinPeriod_Accumulates() public async Task AddUsageAsync_WithinPeriod_Accumulates()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 300); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 300);
@@ -107,7 +108,7 @@ public sealed class TenantLimitStoreTests
public async Task AddUsageAsync_Crossing80Percent_DoesNotSetWarned80() public async Task AddUsageAsync_Crossing80Percent_DoesNotSetWarned80()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700);
@@ -123,7 +124,7 @@ public sealed class TenantLimitStoreTests
public async Task AddUsageAsync_Exhaustion_DoesNotSetFlagsButDisallows() public async Task AddUsageAsync_Exhaustion_DoesNotSetFlagsButDisallows()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 950); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 950);
@@ -140,7 +141,7 @@ public sealed class TenantLimitStoreTests
{ {
// Нулевой расход не инкрементирует, но строка лимита заводится лениво (закрыт путь записи). // Нулевой расход не инкрементирует, но строка лимита заводится лениво (закрыт путь записи).
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 0, CancellationToken.None); BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 0, CancellationToken.None);
@@ -153,7 +154,7 @@ public sealed class TenantLimitStoreTests
public async Task UpdateBudgetAsync_AppliesNewBudgetAndPeriod_AndResetsFlags() public async Task UpdateBudgetAsync_AppliesNewBudgetAndPeriod_AndResetsFlags()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 900, warned80: true, notifiedExhausted: true); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 900, warned80: true, notifiedExhausted: true);
@@ -170,7 +171,7 @@ public sealed class TenantLimitStoreTests
public async Task UpdateBudgetAsync_InvalidArguments_Throw() public async Task UpdateBudgetAsync_InvalidArguments_Throw()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>( await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
@@ -183,7 +184,7 @@ public sealed class TenantLimitStoreTests
public async Task TryMarkWarnedAsync_MarksOnceAtThreshold() public async Task TryMarkWarnedAsync_MarksOnceAtThreshold()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 800); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 800);
@@ -203,7 +204,7 @@ public sealed class TenantLimitStoreTests
public async Task TryMarkNotifiedExhaustedAsync_MarksOnceAtExhaustion() public async Task TryMarkNotifiedExhaustedAsync_MarksOnceAtExhaustion()
{ {
var tenantId = Guid.NewGuid(); var tenantId = Guid.NewGuid();
(DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); (DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active); AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 1000); AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 1000);
@@ -218,7 +219,7 @@ public sealed class TenantLimitStoreTests
[Fact] [Fact]
public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods() public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods()
{ {
(DealDbContext db, TenantLimitStore store) = CreateStore(); (DealDbContext db, ITenantLimitStore store) = CreateStore();
var expiredTenant = Guid.NewGuid(); var expiredTenant = Guid.NewGuid();
var currentTenant = Guid.NewGuid(); var currentTenant = Guid.NewGuid();
// Период месяца начат за 2 месяца назад → истёк к Now; расход и флаги должны обнулиться. // Период месяца начат за 2 месяца назад → истёк к Now; расход и флаги должны обнулиться.
@@ -244,13 +245,13 @@ public sealed class TenantLimitStoreTests
// Создаёт адаптер на уникальной InMemory-БД с фиксированными/системными часами. // Создаёт адаптер на уникальной InMemory-БД с фиксированными/системными часами.
// clock: Источник «сейчас» (null — системные часы). // clock: Источник «сейчас» (null — системные часы).
// Возвращает: Кортеж (контекст для seed/проверок, адаптер). // Возвращает: Кортеж (контекст для seed/проверок, адаптер).
private static (DealDbContext Db, TenantLimitStore Store) CreateStore(Func<DateTimeOffset>? clock = null) private static (DealDbContext Db, ITenantLimitStore Store) CreateStore(Func<DateTimeOffset>? clock = null)
{ {
var options = new DbContextOptionsBuilder<DealDbContext>() var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")) .UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options; .Options;
var db = new DealDbContext(options); var db = new DealDbContext(options);
var store = new TenantLimitStore( ITenantLimitStore store = new TenantLimitStore(
db, TokenBudgetDefaults.Default, new TokenBudgetService(), clock ?? (() => DateTimeOffset.UtcNow)); db, TokenBudgetDefaults.Default, new TokenBudgetService(), clock ?? (() => DateTimeOffset.UtcNow));
return (db, store); return (db, store);
} }
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -10,7 +11,7 @@ public sealed class DiscoverySearchErrorCounterTests
[Fact] [Fact]
public void Next_IncrementsPerTask_ResetClears() public void Next_IncrementsPerTask_ResetClears()
{ {
var counter = new DiscoverySearchErrorCounter(() => DateTimeOffset.UtcNow); IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => DateTimeOffset.UtcNow);
Assert.Equal(1, counter.Next("dt_1")); Assert.Equal(1, counter.Next("dt_1"));
Assert.Equal(2, counter.Next("dt_1")); Assert.Equal(2, counter.Next("dt_1"));
@@ -26,7 +27,7 @@ public sealed class DiscoverySearchErrorCounterTests
public void Next_ExpiredEntry_EvictsAndStartsFromOne() public void Next_ExpiredEntry_EvictsAndStartsFromOne()
{ {
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
var counter = new DiscoverySearchErrorCounter(() => now); IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
Assert.Equal(1, counter.Next("dt_1")); Assert.Equal(1, counter.Next("dt_1"));
Assert.Equal(2, counter.Next("dt_1")); Assert.Equal(2, counter.Next("dt_1"));
@@ -41,7 +42,7 @@ public sealed class DiscoverySearchErrorCounterTests
public void Next_FreshEntry_NotEvictedAfterTtlReset() public void Next_FreshEntry_NotEvictedAfterTtlReset()
{ {
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
var counter = new DiscoverySearchErrorCounter(() => now); IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
Assert.Equal(1, counter.Next("dt_1")); Assert.Equal(1, counter.Next("dt_1"));
@@ -57,7 +58,7 @@ public sealed class DiscoverySearchErrorCounterTests
public void Reset_EvictsExpiredOfOtherTasks() public void Reset_EvictsExpiredOfOtherTasks()
{ {
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
var counter = new DiscoverySearchErrorCounter(() => now); IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
counter.Next("dt_1"); // устареет counter.Next("dt_1"); // устареет
counter.Next("dt_2"); // свежая counter.Next("dt_2"); // свежая
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -15,7 +16,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_CloudProviderWithoutKey_ReturnsNoKeyMessageAndNoHttp() public async Task CheckAsync_CloudProviderWithoutKey_ReturnsNoKeyMessageAndNoHttp()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckResultDto result = await checker.CheckAsync(CloudDeepSeekRequest(), CancellationToken.None); AiCheckResultDto result = await checker.CheckAsync(CloudDeepSeekRequest(), CancellationToken.None);
@@ -33,7 +34,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_LocalProvider_ReturnsOkLocalServerMessageAndNoHttp() public async Task CheckAsync_LocalProvider_ReturnsOkLocalServerMessageAndNoHttp()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
var request = new AiCheckRequest( var request = new AiCheckRequest(
ProviderId: "ollama", ProviderId: "ollama",
BaseUrl: "http://localhost:11434/v1", BaseUrl: "http://localhost:11434/v1",
@@ -57,7 +58,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_UnknownProvider_ReturnsProviderNotAllowedAndNoHttp() public async Task CheckAsync_UnknownProvider_ReturnsProviderNotAllowedAndNoHttp()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
var request = new AiCheckRequest( var request = new AiCheckRequest(
ProviderId: "unknown-provider", ProviderId: "unknown-provider",
BaseUrl: "https://example.com", BaseUrl: "https://example.com",
@@ -77,7 +78,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_NonHttpBaseUrl_ReturnsInvalidBaseUrlAndNoHttp() public async Task CheckAsync_NonHttpBaseUrl_ReturnsInvalidBaseUrlAndNoHttp()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckResultDto result = await checker.CheckAsync( AiCheckResultDto result = await checker.CheckAsync(
CloudDeepSeekRequest(baseUrl: "ftp://api.deepseek.com", key: "sk-1234567890ab"), CloudDeepSeekRequest(baseUrl: "ftp://api.deepseek.com", key: "sk-1234567890ab"),
@@ -94,7 +95,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_SuccessfulHttp_ReturnsOkConnectedWithStatusAndMaskedKey() public async Task CheckAsync_SuccessfulHttp_ReturnsOkConnectedWithStatusAndMaskedKey()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckRequest request = CloudDeepSeekRequest( AiCheckRequest request = CloudDeepSeekRequest(
baseUrl: "https://api.deepseek.com/", baseUrl: "https://api.deepseek.com/",
key: "sk-1234567890ab"); key: "sk-1234567890ab");
@@ -121,7 +122,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_KeyRejectedStatuses_ReturnsKeyRejectedMessage(int statusCode) public async Task CheckAsync_KeyRejectedStatuses_ReturnsKeyRejectedMessage(int statusCode)
{ {
StubHttpMessageHandler handler = CreateHandler((HttpStatusCode)statusCode); StubHttpMessageHandler handler = CreateHandler((HttpStatusCode)statusCode);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckResultDto result = await checker.CheckAsync( AiCheckResultDto result = await checker.CheckAsync(
CloudDeepSeekRequest(key: "sk-1234567890ab"), CloudDeepSeekRequest(key: "sk-1234567890ab"),
@@ -136,7 +137,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_OtherHttpError_ReturnsHttpCheckMessage() public async Task CheckAsync_OtherHttpError_ReturnsHttpCheckMessage()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.InternalServerError); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.InternalServerError);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckResultDto result = await checker.CheckAsync( AiCheckResultDto result = await checker.CheckAsync(
CloudDeepSeekRequest(key: "sk-1234567890ab"), CloudDeepSeekRequest(key: "sk-1234567890ab"),
@@ -152,7 +153,7 @@ public sealed class AiConnectionCheckerTests
{ {
StubHttpMessageHandler handler = new((_, _) => StubHttpMessageHandler handler = new((_, _) =>
Task.FromException<HttpResponseMessage>(new HttpRequestException("Connection refused"))); Task.FromException<HttpResponseMessage>(new HttpRequestException("Connection refused")));
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
AiCheckResultDto result = await checker.CheckAsync( AiCheckResultDto result = await checker.CheckAsync(
CloudDeepSeekRequest(key: "sk-1234567890ab"), CloudDeepSeekRequest(key: "sk-1234567890ab"),
@@ -169,7 +170,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_Anthropic_UsesV1ModelsPathAndApiKeyHeaders() public async Task CheckAsync_Anthropic_UsesV1ModelsPathAndApiKeyHeaders()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
var request = new AiCheckRequest( var request = new AiCheckRequest(
ProviderId: "anthropic", ProviderId: "anthropic",
BaseUrl: "https://api.anthropic.com", BaseUrl: "https://api.anthropic.com",
@@ -197,7 +198,7 @@ public sealed class AiConnectionCheckerTests
public async Task CheckAsync_PrivateBaseUrl_ReturnsSsrfBlockedAndNoHttp() public async Task CheckAsync_PrivateBaseUrl_ReturnsSsrfBlockedAndNoHttp()
{ {
StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK); StubHttpMessageHandler handler = CreateHandler(HttpStatusCode.OK);
AiConnectionChecker checker = CreateChecker(handler); IAiConnectionChecker checker = CreateChecker(handler);
// SSRF-гейт (Security review): приватные/локальные адреса для проверки не-local провайдера запрещены // SSRF-гейт (Security review): приватные/локальные адреса для проверки не-local провайдера запрещены
// (метаданные облака 169.254.169.254, loopback, приватные подсети) — HTTP не выполняется вовсе. // (метаданные облака 169.254.169.254, loopback, приватные подсети) — HTTP не выполняется вовсе.
@@ -2,6 +2,7 @@ using System.Net;
using System.Text; using System.Text;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -16,7 +17,7 @@ public sealed class CbrRateSourceTests
public async Task FetchAsync_SamplePayload_ReturnsRatesToRub() public async Task FetchAsync_SamplePayload_ReturnsRatesToRub()
{ {
StubHttpMessageHandler handler = CreateJsonHandler(SamplePayload); StubHttpMessageHandler handler = CreateJsonHandler(SamplePayload);
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -38,7 +39,7 @@ public sealed class CbrRateSourceTests
{ {
string payload = """{"Valute":{"XCD":{"Nominal":3,"Value":10.0},"TRL":{"Nominal":1,"Value":1.23456789}}}"""; string payload = """{"Valute":{"XCD":{"Nominal":3,"Value":10.0},"TRL":{"Nominal":1,"Value":1.23456789}}}""";
StubHttpMessageHandler handler = CreateJsonHandler(payload); StubHttpMessageHandler handler = CreateJsonHandler(payload);
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -52,7 +53,7 @@ public sealed class CbrRateSourceTests
[Fact] [Fact]
public async Task FetchAsync_HttpErrorStatus_ReturnsNull() public async Task FetchAsync_HttpErrorStatus_ReturnsNull()
{ {
CbrRateSource source = CreateSource(CreateStatusHandler(HttpStatusCode.InternalServerError)); IRatesSource source = CreateSource(CreateStatusHandler(HttpStatusCode.InternalServerError));
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -64,7 +65,7 @@ public sealed class CbrRateSourceTests
{ {
StubHttpMessageHandler handler = new((_, _) => StubHttpMessageHandler handler = new((_, _) =>
Task.FromException<HttpResponseMessage>(new HttpRequestException("Connection refused"))); Task.FromException<HttpResponseMessage>(new HttpRequestException("Connection refused")));
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -75,7 +76,7 @@ public sealed class CbrRateSourceTests
public async Task FetchAsync_InvalidJsonBody_ReturnsNull() public async Task FetchAsync_InvalidJsonBody_ReturnsNull()
{ {
StubHttpMessageHandler handler = CreateJsonHandler("{это не JSON"); StubHttpMessageHandler handler = CreateJsonHandler("{это не JSON");
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -86,7 +87,7 @@ public sealed class CbrRateSourceTests
public async Task FetchAsync_MissingValuteObject_ReturnsNull() public async Task FetchAsync_MissingValuteObject_ReturnsNull()
{ {
StubHttpMessageHandler handler = CreateJsonHandler("""{"Date":"2026-09-05T11:30:00+03:00"}"""); StubHttpMessageHandler handler = CreateJsonHandler("""{"Date":"2026-09-05T11:30:00+03:00"}""");
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -98,7 +99,7 @@ public sealed class CbrRateSourceTests
{ {
string payload = """{"Valute":{"USD":{"Nominal":1,"Value":"abc"},"EUR":{"Nominal":1,"Value":99.9}}}"""; string payload = """{"Valute":{"USD":{"Nominal":1,"Value":"abc"},"EUR":{"Nominal":1,"Value":99.9}}}""";
StubHttpMessageHandler handler = CreateJsonHandler(payload); StubHttpMessageHandler handler = CreateJsonHandler(payload);
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -110,7 +111,7 @@ public sealed class CbrRateSourceTests
{ {
string payload = """{"Valute":{"USD":{"Nominal":0,"Value":92.5}}}"""; string payload = """{"Valute":{"USD":{"Nominal":0,"Value":92.5}}}""";
StubHttpMessageHandler handler = CreateJsonHandler(payload); StubHttpMessageHandler handler = CreateJsonHandler(payload);
CbrRateSource source = CreateSource(handler); IRatesSource source = CreateSource(handler);
Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None); Dictionary<string, double>? rates = await source.FetchAsync(CancellationToken.None);
@@ -17,6 +17,8 @@ using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -54,7 +56,7 @@ public sealed class GrpcAiClassifierTests
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
FakeTenantLimitStore limits = new(); FakeTenantLimitStore limits = new();
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None);
Assert.False(result.Pass); Assert.False(result.Pass);
@@ -85,7 +87,7 @@ public sealed class GrpcAiClassifierTests
{ {
service.FilterUnavailable = true; service.FilterUnavailable = true;
(FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => classifier.FilterAsync("текст", CancellationToken.None)); () => classifier.FilterAsync("текст", CancellationToken.None));
@@ -99,7 +101,7 @@ public sealed class GrpcAiClassifierTests
{ {
string text = new string('а', 5000); string text = new string('а', 5000);
(FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.FilterAsync(text, CancellationToken.None); await classifier.FilterAsync(text, CancellationToken.None);
@@ -149,7 +151,7 @@ public sealed class GrpcAiClassifierTests
}); });
FakeTenantLimitStore limits = new(); FakeTenantLimitStore limits = new();
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
Assert.Equal("Middle Python в команду", parsed.Title); Assert.Equal("Middle Python в команду", parsed.Title);
@@ -190,7 +192,7 @@ public sealed class GrpcAiClassifierTests
(FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
FakeTenantLimitStore limits = new(); FakeTenantLimitStore limits = new();
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
@@ -209,7 +211,7 @@ public sealed class GrpcAiClassifierTests
{ {
service.ClassifyUnavailable = true; service.ClassifyUnavailable = true;
(FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => classifier.ClassifyAsync("текст", CancellationToken.None)); () => classifier.ClassifyAsync("текст", CancellationToken.None));
@@ -236,7 +238,7 @@ public sealed class GrpcAiClassifierTests
}, },
}.ToJsonString()); }.ToJsonString());
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync("текст", CancellationToken.None); await classifier.ClassifyAsync("текст", CancellationToken.None);
// ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога. // ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога.
@@ -260,7 +262,7 @@ public sealed class GrpcAiClassifierTests
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000); string text = new string('б', 6000);
(FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None); await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -285,7 +287,7 @@ public sealed class GrpcAiClassifierTests
FakeKanjStore kanjStore, FakeKanjStore kanjStore,
FakeTenantLimitStore? limits = null) FakeTenantLimitStore? limits = null)
{ {
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiClassifier( return new GrpcAiClassifier(
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Storage.Services; using Deal.Infrastructure.Integrations.Storage.Services;
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -13,7 +14,7 @@ public sealed class LocalFileStorageTests : IDisposable
private static readonly byte[] SampleContent = Encoding.UTF8.GetBytes("файл-вложение-deal-123"); private static readonly byte[] SampleContent = Encoding.UTF8.GetBytes("файл-вложение-deal-123");
private readonly string _tempRoot; private readonly string _tempRoot;
private readonly LocalFileStorage _storage; private readonly IFileStorage _storage;
public LocalFileStorageTests() public LocalFileStorageTests()
{ {
@@ -8,6 +8,7 @@ using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -96,7 +97,7 @@ public sealed class TokenUsageRecorderTests
{ {
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
var tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
var recorder = new TokenUsageRecorder( var recorder = new TokenUsageRecorder(
settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())); settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore()));
@@ -144,7 +145,7 @@ public sealed class TokenUsageRecorderTests
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore(); var limits = new FakeTenantLimitStore();
var events = new FakeTokenUsageEventStore(); var events = new FakeTokenUsageEventStore();
var tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events)); var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events));
return (settings, limits, events, recorder); return (settings, limits, events, recorder);
@@ -25,7 +25,7 @@ public sealed class CoreIngressClientTests
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
try try
{ {
CoreIngressClient client = CreateClient(endpoint); ICoreIngressClient client = CreateClient(endpoint);
PushSourceReply reply = await client.PushSourceAsync( PushSourceReply reply = await client.PushSourceAsync(
TenantId, TenantId,
@@ -65,7 +65,7 @@ public sealed class CoreIngressClientTests
try try
{ {
server.MonitoredIds.Add("-1001234567890"); server.MonitoredIds.Add("-1001234567890");
CoreIngressClient client = CreateClient(endpoint); ICoreIngressClient client = CreateClient(endpoint);
var entries = new List<DialogEntry> var entries = new List<DialogEntry>
{ {
new() { Id = "-1001234567890", Name = "IT Канал", Kind = "channel" }, new() { Id = "-1001234567890", Name = "IT Канал", Kind = "channel" },
@@ -91,7 +91,7 @@ public sealed class CoreIngressClientTests
public async Task PushSource_UnreachableCore_ThrowsSessionException() public async Task PushSource_UnreachableCore_ThrowsSessionException()
{ {
// Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с). // Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с).
CoreIngressClient client = CreateClient("http://127.0.0.1:1"); ICoreIngressClient client = CreateClient("http://127.0.0.1:1");
SessionException exception = await Assert.ThrowsAsync<SessionException>( SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => client.PushSourceAsync( () => client.PushSourceAsync(
@@ -7,6 +7,7 @@ using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions; using Deal.Telegram.Sessions;
using Grpc.Core; using Grpc.Core;
using Grpc.Net.Client; using Grpc.Net.Client;
using Deal.Telegram.Core;
namespace Deal.Telegram.Core; namespace Deal.Telegram.Core;
@@ -44,7 +45,7 @@ public sealed class CoreIngressClient : ICoreIngressClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<PushSourceReply> PushSourceAsync( async Task<PushSourceReply> ICoreIngressClient.PushSourceAsync(
string tenantId, string tenantId,
PushSourceRequest request, PushSourceRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -61,7 +62,7 @@ public sealed class CoreIngressClient : ICoreIngressClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<string>> SyncDialogsAsync( async Task<IReadOnlyList<string>> ICoreIngressClient.SyncDialogsAsync(
string tenantId, string tenantId,
IReadOnlyList<DialogEntry> entries, IReadOnlyList<DialogEntry> entries,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -1,3 +1,4 @@
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Dialogs; namespace Deal.Telegram.Dialogs;
/// <summary> /// <summary>
@@ -6,7 +7,7 @@ namespace Deal.Telegram.Dialogs;
public sealed class RandomBackfillPacer : IBackfillPacer public sealed class RandomBackfillPacer : IBackfillPacer
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task WaitAsync( async Task IBackfillPacer.WaitAsync(
double minSeconds, double minSeconds,
double maxSeconds, double maxSeconds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -1,3 +1,4 @@
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Telegram; namespace Deal.Telegram.Telegram;
/// <summary> /// <summary>
@@ -6,7 +7,7 @@ namespace Deal.Telegram.Telegram;
public sealed class ClientFactory : ITelegramClientFactory public sealed class ClientFactory : ITelegramClientFactory
{ {
/// <inheritdoc /> /// <inheritdoc />
public ISessionClient Create( ISessionClient ITelegramClientFactory.Create(
int apiId, int apiId,
string apiHash, string apiHash,
byte[]? storedSession) byte[]? storedSession)
@@ -5,6 +5,7 @@ using Grpc.Core;
using TL; using TL;
using WTelegram; using WTelegram;
using RpcException = TL.RpcException; using RpcException = TL.RpcException;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Telegram; namespace Deal.Telegram.Telegram;
@@ -82,7 +83,7 @@ public sealed class WTelegramSessionClient : ISessionClient
public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes); public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes);
/// <inheritdoc /> /// <inheritdoc />
public async Task ConnectAsync(CancellationToken cancellationToken) async Task ISessionClient.ConnectAsync(CancellationToken cancellationToken)
{ {
if (IsConnected) if (IsConnected)
{ {
@@ -94,7 +95,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task RequestCodeAsync(string phone, CancellationToken cancellationToken) async Task ISessionClient.RequestCodeAsync(string phone, CancellationToken cancellationToken)
{ {
_phone = phone; _phone = phone;
_phoneCodeHash = null; _phoneCodeHash = null;
@@ -118,7 +119,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken) async Task<string?> ISessionClient.SubmitCodeAsync(string code, CancellationToken cancellationToken)
{ {
if (_phoneAlreadyAuthorized) if (_phoneAlreadyAuthorized)
{ {
@@ -158,7 +159,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SubmitPasswordAsync(string password, CancellationToken cancellationToken) async Task ISessionClient.SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{ {
try try
{ {
@@ -178,7 +179,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken) async Task ISessionClient.StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken)
{ {
try try
{ {
@@ -196,13 +197,13 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LogOutAsync(CancellationToken cancellationToken) async Task ISessionClient.LogOutAsync(CancellationToken cancellationToken)
{ {
await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false); await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> GetAccountAsync(CancellationToken cancellationToken) async Task<string> ISessionClient.GetAccountAsync(CancellationToken cancellationToken)
{ {
UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false); UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false);
string username = users.OfType<User>().FirstOrDefault()?.username ?? string.Empty; string username = users.OfType<User>().FirstOrDefault()?.username ?? string.Empty;
@@ -221,7 +222,7 @@ public sealed class WTelegramSessionClient : ISessionClient
public event Func<TelegramMessage, Task>? MessageReceived; public event Func<TelegramMessage, Task>? MessageReceived;
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken) async Task<IReadOnlyList<TelegramDialog>> ISessionClient.GetDialogsAsync(int limit, CancellationToken cancellationToken)
{ {
Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false); Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false);
(DialogBase[] dialogs, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackDialogs(result); (DialogBase[] dialogs, Dictionary<long, ChatBase> chats, Dictionary<long, User> users) = UnpackDialogs(result);
@@ -241,7 +242,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync( async Task<IReadOnlyList<TelegramMessage>> ISessionClient.GetMessagesAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -265,7 +266,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramMessage?> GetMessageAsync( async Task<TelegramMessage?> ISessionClient.GetMessageAsync(
string dialogId, string dialogId,
long msgId, long msgId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -295,7 +296,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) async Task ISessionClient.MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{ {
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false); await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false);
@@ -303,7 +304,7 @@ public sealed class WTelegramSessionClient : ISessionClient
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync( async Task<IReadOnlyList<TelegramDialog>> ISessionClient.SearchAsync(
string query, string query,
int limit, int limit,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -327,7 +328,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken) async Task<TelegramSourceInfo> ISessionClient.GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{ {
TelegramSourceInfo unknown = DefaultSourceInfo(dialogId); TelegramSourceInfo unknown = DefaultSourceInfo(dialogId);
if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId))
@@ -361,7 +362,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<DiscoveryReadResult> ReadForEvalAsync( async Task<DiscoveryReadResult> ISessionClient.ReadForEvalAsync(
string dialogId, string dialogId,
int limit, int limit,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -410,7 +411,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken cancellationToken) async Task ISessionClient.JoinAsync(string username, CancellationToken cancellationToken)
{ {
Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false); Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false);
CacheEntities(resolved.chats.Values, resolved.users.Values); CacheEntities(resolved.chats.Values, resolved.users.Values);
@@ -425,7 +426,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) async Task ISessionClient.LeaveAsync(string dialogId, CancellationToken cancellationToken)
{ {
if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel) if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel)
{ {
@@ -923,7 +924,7 @@ public sealed class WTelegramSessionClient : ISessionClient
// Канал/пользователь без access_hash (рестарт/новый источник): доливаем сущности // Канал/пользователь без access_hash (рестарт/новый источник): доливаем сущности
// первой страницей списка диалогов — каталог ядра строится из неё же (лимит 500, как refresh). // первой страницей списка диалогов — каталог ядра строится из неё же (лимит 500, как refresh).
await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); await ((ISessionClient)this).GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false);
return BuildPeer(isChannel, isChat, isUser, rawId) return BuildPeer(isChannel, isChat, isUser, rawId)
?? throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog); ?? throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog);
} }
@@ -945,7 +946,7 @@ public sealed class WTelegramSessionClient : ISessionClient
} }
} }
await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); await ((ISessionClient)this).GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false);
lock (_entityCacheGate) lock (_entityCacheGate)
{ {
return _entityAccessHashes.TryGetValue(dialogId, out long accessHash) return _entityAccessHashes.TryGetValue(dialogId, out long accessHash)