Перевести FakeAuthStore на NSubstitute

This commit is contained in:
2026-09-13 02:54:04 +03:00
parent a3b193e618
commit d07875f146
24 changed files with 242 additions and 1214 deletions
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
/// </summary> /// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param> /// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param> /// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param> /// <param name="utcNow">Источник текущего времени (UTC). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard( public DiscoveryBanGuard(
IDiscoveryStore store, IDiscoveryStore store,
ISettingsStore settings, ISettingsStore settings,
@@ -1,85 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IFileStorage"/> для unit-тестов файлов карточки
/// </summary>
public sealed class FakeFileStorage : IFileStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _contentTypes = new(StringComparer.Ordinal);
private readonly List<string> _deletedKeys = [];
/// <summary>
/// objectKey всех объектов, сохранённых на данный момент
/// </summary>
public IReadOnlyList<string> StoredObjectKeys => _objects.Keys.ToList();
/// <summary>
/// objectKey всех удалений в порядке вызовов DeleteAsync
/// </summary>
public IReadOnlyList<string> DeletedKeys => _deletedKeys.ToList();
/// <summary>
/// Содержимое сохранённого объекта по ключу либо null — объекта нет
/// </summary>
/// <param name="objectKey">Ключ объекта (opaque).</param>
public byte[]? ContentOf(string objectKey)
{
return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null;
}
/// <inheritdoc />
public Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(content);
using MemoryStream buffer = new();
if (content.CanSeek && content.Position != 0)
{
content.Position = 0; // контракт порта: Put читает всё содержимое с позиции 0 (Ruling T6)
}
content.CopyTo(buffer);
_objects[objectKey] = buffer.ToArray();
_contentTypes[objectKey] = contentType;
return Task.FromResult(objectKey);
}
/// <inheritdoc />
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(new MemoryStream(content));
}
/// <inheritdoc />
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<FileMeta?>(null);
}
string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty;
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, content.Length, contentType));
}
/// <inheritdoc />
public Task DeleteAsync(string objectKey, CancellationToken ct)
{
_deletedKeys.Add(objectKey);
_objects.Remove(objectKey);
return Task.CompletedTask;
}
}
@@ -1,614 +0,0 @@
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// In-memory реализация <see cref="IDiscoveryStore"/> для unit-тестов Discovery.
/// </summary>
public sealed class FakeDiscoveryStore : IDiscoveryStore
{
private readonly List<DiscoveryTaskDto> _tasks = [];
private readonly List<DiscoveryCandidateDto> _candidates = [];
private readonly List<DiscoveryBlacklistDto> _blacklist = [];
private readonly List<DiscoveryLogDto> _log = [];
private readonly HashSet<string> _monitoredIds = new(StringComparer.Ordinal);
private readonly Func<DateTimeOffset> _clock;
/// <summary>
/// Создаёт фейк с «часами» по умолчанию
/// </summary>
/// <param name="clock">Источник текущего времени (по умолчанию <see cref="DateTimeOffset.UtcNow"/>).</param>
public FakeDiscoveryStore(Func<DateTimeOffset>? clock = null)
{
_clock = clock ?? (() => DateTimeOffset.UtcNow);
}
/// <summary>
/// Задачи фейка
/// </summary>
public IReadOnlyList<DiscoveryTaskDto> Tasks => _tasks.ToList();
/// <summary>
/// Кандидаты фейка
/// </summary>
public IReadOnlyList<DiscoveryCandidateDto> Candidates => _candidates.ToList();
/// <summary>
/// Чёрный список фейка
/// </summary>
public IReadOnlyList<DiscoveryBlacklistDto> Blacklist => _blacklist.ToList();
/// <summary>
/// Лог фейка в порядке записи
/// </summary>
public IReadOnlyList<DiscoveryLogDto> Log => _log.ToList();
/// <summary>
/// Кладёт задачу напрямую
/// </summary>
/// <param name="task">Задача как если бы была прочитана адаптером.</param>
public void SeedTask(DiscoveryTaskDto task)
{
_tasks.Add(task);
}
/// <summary>
/// Кладёт кандидата напрямую
/// </summary>
/// <param name="candidate">Кандидат как если бы был прочитан адаптером.</param>
public void SeedCandidate(DiscoveryCandidateDto candidate)
{
_candidates.Add(candidate);
}
/// <summary>
/// Помечает источник «уже мониторится» — есть в каталоге Dialogs.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
public void SeedMonitored(string dialogId)
{
_monitoredIds.Add(dialogId);
}
/// <summary>
/// Кладёт событие лога напрямую
/// </summary>
/// <param name="event">Событие (join_auto/…).</param>
/// <param name="createdAt">Момент события (UTC).</param>
/// <param name="taskId">Id задачи (по умолчанию — произвольный).</param>
public void SeedLog(
string @event,
DateTimeOffset createdAt,
string taskId = "dt_x")
{
_log.Add(new DiscoveryLogDto("dl_x", taskId, @event, string.Empty, createdAt.ToUnixTimeMilliseconds()));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryTaskDto>>(_tasks
.OrderBy(task => task.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
{
return Task.FromResult(_tasks.FirstOrDefault(task => task.Id == taskId));
}
/// <inheritdoc />
public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_tasks.Add(new DiscoveryTaskDto(
row.Id,
row.Name,
row.Description,
row.Keywords.ToList(),
row.MinSubscribers,
row.Lang,
row.Threshold,
row.SampleSize,
row.PlanJoins,
row.AutoJoin,
DiscoveryTaskStatuses.Draft,
SearchIdx: 0,
SearchDone: false,
Found: 0,
Evaluated: 0,
Joined: 0,
Rejected: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> PatchTaskAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index];
if (patch.Name is not null)
{
task = task with { Name = patch.Name };
}
if (patch.Description is not null)
{
task = task with { Description = patch.Description };
}
if (patch.Keywords is not null)
{
task = task with { Keywords = patch.Keywords.ToList() };
}
if (patch.MinSubscribers is int minSubscribers)
{
task = task with { MinSubscribers = minSubscribers };
}
if (patch.Lang is not null)
{
task = task with { Lang = patch.Lang };
}
if (patch.Threshold is int threshold)
{
task = task with { Threshold = threshold };
}
if (patch.SampleSize is int sampleSize)
{
task = task with { SampleSize = sampleSize };
}
if (patch.PlanJoins is int planJoins)
{
task = task with { PlanJoins = planJoins };
}
if (patch.AutoJoin is bool autoJoin)
{
task = task with { AutoJoin = autoJoin };
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{
if (!_tasks.Any(task => task.Id == taskId))
{
return false;
}
_tasks.RemoveAll(task => task.Id == taskId);
_candidates.RemoveAll(candidate => candidate.TaskId == taskId);
_log.RemoveAll(entry => entry.TaskId == taskId);
return true;
}
/// <inheritdoc />
public Task<bool> SetTaskRunningAsync(
string taskId,
bool resetProgress,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index] with { Status = DiscoveryTaskStatuses.Running };
if (resetProgress)
{
task = task with
{
SearchIdx = 0,
SearchDone = false,
Found = 0,
Evaluated = 0,
Joined = 0,
Rejected = 0,
};
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Paused,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Done,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> BumpTaskCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0 || n <= 0)
{
return Task.FromResult(index >= 0);
}
DiscoveryTaskDto task = field switch
{
DiscoveryCounterField.Found => _tasks[index] with { Found = _tasks[index].Found + n },
DiscoveryCounterField.Evaluated => _tasks[index] with { Evaluated = _tasks[index].Evaluated + n },
DiscoveryCounterField.Joined => _tasks[index] with { Joined = _tasks[index].Joined + n },
DiscoveryCounterField.Rejected => _tasks[index] with { Rejected = _tasks[index].Rejected + n },
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"),
};
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> AdvanceSearchAsync(
string taskId,
int nextIndex,
bool searchDone,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
SearchIdx = nextIndex,
SearchDone = searchDone,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IEnumerable<DiscoveryTaskDto> active = _tasks
.Where(task => !DiscoveryTaskStatuses.IsFinished(task.Status));
if (excludeTaskId is not null)
{
active = active.Where(task => task.Id != excludeTaskId);
}
return Task.FromResult(active.Sum(task => task.PlanJoins));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
string taskId,
string? status,
CancellationToken ct)
{
IEnumerable<DiscoveryCandidateDto> query = _candidates.Where(candidate => candidate.TaskId == taskId);
if (status is not null)
{
query = query.Where(candidate => candidate.Status == status);
}
return Task.FromResult<IReadOnlyList<DiscoveryCandidateDto>>(query
.OrderBy(candidate => candidate.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_candidates.FirstOrDefault(candidate => candidate.DialogId == dialogId));
}
/// <inheritdoc />
public Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_monitoredIds.Contains(dialogId));
}
/// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.Any(entry => entry.DialogId == dialogId));
}
/// <inheritdoc />
public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_candidates.Add(new DiscoveryCandidateDto(
row.DialogId,
row.TaskId,
row.Name,
row.Username,
row.Kind,
row.Hue,
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
DiscoveryCandidateStatuses.New,
AutoJoined: false,
JoinFailures: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
{
_candidates.RemoveAll(candidate => candidate.DialogId == dialogId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> PatchCandidateAsync(
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryCandidateDto candidate = _candidates[index];
if (patch.Name is not null)
{
candidate = candidate with { Name = patch.Name };
}
if (patch.Username is not null)
{
candidate = candidate with { Username = patch.Username };
}
if (patch.Kind is not null)
{
candidate = candidate with { Kind = patch.Kind };
}
if (patch.Hue is not null)
{
candidate = candidate with { Hue = patch.Hue };
}
if (patch.Participants is int participants)
{
candidate = candidate with { Participants = participants };
}
if (patch.LangRu is bool langRu)
{
candidate = candidate with { LangRu = langRu };
}
if (patch.Marks is not null)
{
candidate = candidate with { Marks = patch.Marks.ToList() };
}
if (patch.Topics is not null)
{
candidate = candidate with { Topics = patch.Topics.ToList() };
}
if (patch.FitRatio is double fitRatio)
{
candidate = candidate with { FitRatio = fitRatio };
}
if (patch.AutoJoined is bool autoJoined)
{
candidate = candidate with { AutoJoined = autoJoined };
}
_candidates[index] = candidate with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetCandidateStatusAsync(
string dialogId,
string status,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = status,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetCandidateJoinedAsync(
string dialogId,
bool autoJoined,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Joined,
AutoJoined = autoJoined,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0 || _candidates[index].Status != DiscoveryCandidateStatuses.Review)
{
return Task.FromResult<int?>(null);
}
DiscoveryCandidateDto candidate = _candidates[index] with
{
JoinFailures = _candidates[index].JoinFailures + 1,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
_candidates[index] = candidate;
return Task.FromResult<int?>(candidate.JoinFailures);
}
/// <inheritdoc />
public Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Rejected,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task UpsertBlacklistAsync(
string dialogId,
string name,
string reason,
CancellationToken ct)
{
DiscoveryBlacklistDto? existing = _blacklist.FirstOrDefault(entry => entry.DialogId == dialogId);
if (existing is null)
{
_blacklist.Add(new DiscoveryBlacklistDto(dialogId, name, reason, _clock().ToUnixTimeMilliseconds()));
}
else
{
_blacklist[_blacklist.IndexOf(existing)] = existing with { Name = name, Reason = reason };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{
_blacklist.RemoveAll(entry => entry.DialogId == dialogId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.FirstOrDefault(entry => entry.DialogId == dialogId));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryBlacklistDto>>(_blacklist
.OrderByDescending(entry => entry.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task AddLogAsync(
string logId,
string taskId,
string logEvent,
string text,
CancellationToken ct)
{
_log.Add(new DiscoveryLogDto(logId, taskId, logEvent, text, _clock().ToUnixTimeMilliseconds()));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountLogEventAsync(
string logEvent,
DateTimeOffset sinceUtc,
CancellationToken ct)
{
long sinceMs = sinceUtc.ToUnixTimeMilliseconds();
return Task.FromResult(_log.Count(entry => entry.Event == logEvent && entry.CreatedAt >= sinceMs));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
string taskId,
int limit,
CancellationToken ct)
{
IReadOnlyList<DiscoveryLogDto> entries = _log
.Where(entry => entry.TaskId == taskId)
.OrderByDescending(entry => entry.CreatedAt)
.Take(limit)
.ToList();
return Task.FromResult(entries);
}
}
@@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary> /// <summary>
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере». /// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере».
/// </summary> /// </summary>
public sealed class AuthServiceTests public sealed class AuthServiceTests
{ {
@@ -17,17 +17,17 @@ public sealed class AuthServiceTests
private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid UserId = Guid.NewGuid();
private static readonly Guid UserTenantId = Guid.NewGuid(); private static readonly Guid UserTenantId = Guid.NewGuid();
private readonly FakeAuthStore _store; private readonly TestAuthStore _store;
private readonly TestTenantStore _tenantStore; private readonly TestTenantStore _tenantStore;
private readonly AuthService _service; private readonly AuthService _service;
public AuthServiceTests() public AuthServiceTests()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
_store = new FakeAuthStore(); _store = new TestAuthStore();
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); _tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); _store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
_service = new AuthService(_store, passwordHasher, _tenantStore.Repository); _service = new AuthService(_store.Store, passwordHasher, _tenantStore.Repository);
} }
[Fact] [Fact]
@@ -133,7 +133,7 @@ public sealed class AuthServiceTests
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None); var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.Null(user); Assert.Null(user);
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища. // Очистка «протухших» сессий вызвана и удалила сессию из хранилища.
Assert.Contains("delete-expired", _store.Calls); Assert.Contains("delete-expired", _store.Calls);
Assert.Empty(_store.Sessions); Assert.Empty(_store.Sessions);
} }
@@ -204,10 +204,10 @@ public sealed class AuthServiceTests
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession() public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository); var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None); var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
@@ -225,10 +225,10 @@ public sealed class AuthServiceTests
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает // Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты). // приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository); var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None); var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
@@ -266,12 +266,12 @@ public sealed class AuthServiceTests
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser() public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var multiUserStore = new FakeAuthStore(); var multiUserStore = new TestAuthStore();
var firstUserId = Guid.NewGuid(); var firstUserId = Guid.NewGuid();
var secondUserId = Guid.NewGuid(); var secondUserId = Guid.NewGuid();
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1"))); multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1")));
multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2"))); multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2")));
var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore.Repository); var multiUserService = new AuthService(multiUserStore.Store, passwordHasher, _tenantStore.Repository);
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере). // login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None); var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
@@ -294,9 +294,9 @@ public sealed class AuthServiceTests
// Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина). // Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина).
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var crossStore = new FakeAuthStore(); var crossStore = new TestAuthStore();
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p"))); crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore.Repository); var crossService = new AuthService(crossStore.Store, passwordHasher, _tenantStore.Repository);
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None); var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(cross.Ok); Assert.False(cross.Ok);
@@ -1,123 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IAuthStore"/> для юнит-тестов AuthService.
/// </summary>
public sealed class FakeAuthStore : IAuthStore
{
private readonly List<StoredUserDto> _users = [];
private readonly List<SessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Пользователи хранилища.
/// </summary>
public IReadOnlyList<StoredUserDto> Users => _users;
/// <summary>
/// Сессии хранилища.
/// </summary>
public IReadOnlyList<SessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет пользователя
/// </summary>
/// <param name="user">Пользователь.</param>
public void AddUser(StoredUserDto user) => _users.Add(user);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(SessionDto session) => _sessions.Add(session);
/// <inheritdoc />
public Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct) =>
Task.FromResult(_users.SingleOrDefault(u => u.Login == login));
/// <inheritdoc />
public Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
/// <inheritdoc />
public Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct)
{
var user = _users.SingleOrDefault(u => u.Id == userId);
return Task.FromResult(
user is null
? null
: new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status));
}
/// <inheritdoc />
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<UserIdentityDto>>(_users
.Where(u => u.TenantId == tenantId)
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
.ToList());
/// <inheritdoc />
public Task CreateUserAsync(StoredUserDto user, CancellationToken ct)
{
_users.Add(user);
_calls.Add($"create-user:{user.Id}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{
_sessions.Add(session);
_calls.Add($"create-session:{session.UserId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
{
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
_calls.Add($"delete-session:{tokenHash}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct)
{
_sessions.RemoveAll(s => s.UserId == userId);
_calls.Add($"delete-user-sessions:{userId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdatePasswordHashAsync(
Guid userId,
string passwordHash,
CancellationToken ct)
{
int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0)
{
var user = _users[index];
_users[index] = user with { PasswordHash = passwordHash };
}
_calls.Add($"update-password-hash:{userId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
return Task.CompletedTask;
}
}
@@ -1,81 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IOperatorAuthStore"/> для юнит-тестов OperatorAuthService.
/// </summary>
public sealed class FakeOperatorAuthStore : IOperatorAuthStore
{
private readonly List<StoredOperatorDto> _operators = [];
private readonly List<OperatorSessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Операторы хранилища.
/// </summary>
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
/// <summary>
/// Сессии операторов хранилища.
/// </summary>
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет оператора
/// </summary>
/// <param name="operatorRecord">Оператор.</param>
public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(OperatorSessionDto session) => _sessions.Add(session);
/// <inheritdoc />
public Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct) =>
Task.FromResult(_operators.SingleOrDefault(o => o.Login == login));
/// <inheritdoc />
public Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
/// <inheritdoc />
public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
_operators.Add(operatorRecord);
_calls.Add($"create-operator:{operatorRecord.Id}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct)
{
_sessions.Add(session);
_calls.Add($"create-session:{session.OperatorId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
{
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
_calls.Add($"delete-session:{tokenHash}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
return Task.CompletedTask;
}
}
@@ -1,57 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantRepository"/> для unit-тестов
/// </summary>
public sealed class FakeTenantRepository : ITenantRepository
{
private readonly IReadOnlyList<TenantRecordDto> _tenants;
private readonly List<(int Offset, int Limit)> _pageRequests = [];
/// <summary>
/// Создаёт реестр с фиксированным списком тенантов.
/// </summary>
/// <param name="tenants">Тенанты реестра (как строки public.tenants).</param>
public FakeTenantRepository(params TenantRecordDto[] tenants)
{
_tenants = tenants.ToList();
}
/// <summary>
/// Запрошенные страницы (offset, limit) в порядке обращений.
/// </summary>
public IReadOnlyList<(int Offset, int Limit)> PageRequests => _pageRequests;
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{
_pageRequests.Add((offset, limit));
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
return Task.FromResult(page);
}
/// <inheritdoc />
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
throw new NotSupportedException("FindByIdAsync не используется тестами реестра");
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
Guid id,
string status,
CancellationToken ct) =>
throw new NotSupportedException("UpdateStatusAsync не используется тестами реестра");
/// <inheritdoc />
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) =>
throw new NotSupportedException("CreateAsync не используется тестами реестра");
}
@@ -1,68 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantRepository"/> с поддержкой создания тенантов.
/// </summary>
public sealed class FakeTenantStore : ITenantRepository
{
private readonly List<TenantRecordDto> _tenants;
/// <summary>
/// Создаёт хранилище, опционально с предзаполненным реестром тенантов.
/// </summary>
/// <param name="seed">Тенанты, которые уже есть в реестре (как строки public.tenants).</param>
public FakeTenantStore(params TenantRecordDto[] seed)
{
_tenants = seed.ToList();
}
/// <summary>
/// Записи реестра в порядке добавления.
/// </summary>
public IReadOnlyList<TenantRecordDto> Tenants => _tenants;
/// <inheritdoc />
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
Task.FromResult(_tenants.FirstOrDefault(tenant => tenant.Id == id));
/// <inheritdoc />
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct)
{
_tenants.Add(tenant);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
return Task.FromResult(page);
}
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
Guid id,
string status,
CancellationToken ct)
{
int index = _tenants.FindIndex(tenant => tenant.Id == id);
if (index < 0)
{
return Task.FromResult(false);
}
var current = _tenants[index];
_tenants[index] = current with { Status = status };
return Task.FromResult(true);
}
}
@@ -1,71 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITokenUsageEventStore"/> для юнит/HTTP-тестов.
/// </summary>
public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore
{
private readonly List<TokenUsageEventDto> _records = [];
/// <summary>
/// Записи хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<TokenUsageEventDto> Records => _records;
/// <inheritdoc />
public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
{
_records.Add(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
{
IEnumerable<TokenUsageEventDto> filtered = _records.Where(record =>
(query.TenantId is null || record.TenantId == query.TenantId) &&
(string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) &&
(string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) &&
(string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) &&
(query.From is null || record.At >= query.From.Value) &&
(query.To is null || record.At <= query.To.Value));
IReadOnlyList<TokenUsageAggregateDto> result = query.GroupBy switch
{
TokenUsageGroupBys.Day => GroupByDay(filtered),
TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")),
TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider),
TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model),
_ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)),
};
return Task.FromResult(result);
}
private static IReadOnlyList<TokenUsageAggregateDto> GroupByDay(IEnumerable<TokenUsageEventDto> source) =>
source
.GroupBy(record => record.At.UtcDateTime.Date)
.OrderBy(group => group.Key)
.Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group))
.ToList();
private static IReadOnlyList<TokenUsageAggregateDto> GroupByString(IEnumerable<TokenUsageEventDto> source, Func<TokenUsageEventDto, string> keySelector) =>
source
.GroupBy(keySelector)
.Select(group => ToAggregate(group.Key, group))
.OrderByDescending(row => row.TotalTokens)
.ToList();
private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable<TokenUsageEventDto> group)
{
List<TokenUsageEventDto> rows = group.ToList();
return new TokenUsageAggregateDto(
key,
rows.Sum(row => row.PromptTokens),
rows.Sum(row => row.CompletionTokens),
rows.Sum(row => row.TotalTokens),
rows.Count);
}
}
@@ -29,7 +29,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
@@ -70,7 +70,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -87,7 +87,7 @@ public sealed class JoinFlowTests
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -108,7 +108,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -126,7 +126,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -142,7 +142,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
@@ -159,7 +159,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New()); var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -203,7 +203,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
@@ -221,7 +221,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -248,7 +248,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
@@ -267,7 +267,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -288,7 +288,7 @@ public sealed class JoinFlowTests
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится. // Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -311,7 +311,7 @@ public sealed class JoinFlowTests
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано. // Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New()); var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -353,17 +353,17 @@ public sealed class JoinFlowTests
// ─── Хелперы ───────────────────────────────────────────────────────── // ─── Хелперы ─────────────────────────────────────────────────────────
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие). // Собирает JoinService на общих подставках хранилищ (все состояния — свежие).
private static JoinService NewService( private static JoinService NewService(
TestInviteStore inviteStore, TestInviteStore inviteStore,
TestTenantStore tenantStore, TestTenantStore tenantStore,
ITenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, TestAuthStore authStore,
IPasswordHasher passwordHasher) => IPasswordHasher passwordHasher) =>
new( new(
new InvitesService(inviteStore.Store), new InvitesService(inviteStore.Store),
new TenantService(tenantStore.Repository, provisioner), new TenantService(tenantStore.Repository, provisioner),
authStore, authStore.Store,
passwordHasher, passwordHasher,
tenantStore.Repository); tenantStore.Repository);
@@ -18,7 +18,7 @@ public sealed class TenantAdminServiceTests
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var service = NewService(tenantStore, new FakeAuthStore(), provisioner); var service = NewService(tenantStore, new TestAuthStore(), provisioner);
TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None); TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
@@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword() public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var hasher = TestHashers.New(); var hasher = TestHashers.New();
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher); var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
@@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant() public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
var service = NewService(tenantStore, authStore); var service = NewService(tenantStore, authStore);
@@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail() public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore()); var service = NewService(tenantStore, new TestAuthStore());
TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None); TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
@@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired() public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
{ {
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore()); var service = NewService(tenantStore, new TestAuthStore());
TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None); TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
@@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests
var tenantStore = new TestTenantStore( var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(NewUser(FirstTenantId, "a@example.com")); authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(FirstTenantId, "b@example.com")); authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
@@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers() public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(NewUser(FirstTenantId, "a@example.com")); authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com")); authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
var service = NewService(tenantStore, authStore); var service = NewService(tenantStore, authStore);
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
[Fact] [Fact]
public async Task GetAsync_ForUnknownTenant_ReturnsNull() public async Task GetAsync_ForUnknownTenant_ReturnsNull()
{ {
var service = NewService(new TestTenantStore(), new FakeAuthStore()); var service = NewService(new TestTenantStore(), new TestAuthStore());
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None)); Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
} }
@@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged() public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore()); var service = NewService(tenantStore, new TestAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange() public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
{ {
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore()); var service = NewService(tenantStore, new TestAuthStore());
// Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность). // Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
[Fact] [Fact]
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound() public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound()
{ {
var service = NewService(new TestTenantStore(), new FakeAuthStore()); var service = NewService(new TestTenantStore(), new TestAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None); TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
@@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests
Assert.Null(result.Tenant); Assert.Null(result.Tenant);
} }
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку). // Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку).
private static TenantAdminService NewService( private static TenantAdminService NewService(
TestTenantStore tenantStore, TestTenantStore tenantStore,
FakeAuthStore authStore, TestAuthStore authStore,
TestTenantProvisioner? provisioner = null, TestTenantProvisioner? provisioner = null,
IPasswordHasher? hasher = null) IPasswordHasher? hasher = null)
{ {
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner); var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
return new TenantAdminService(tenantStore.Repository, authStore, tenantService, hasher ?? TestHashers.New()); return new TenantAdminService(tenantStore.Repository, authStore.Store, tenantService, hasher ?? TestHashers.New());
} }
// Пользователь тенанта (активный, без хэша — для чтения). // Пользователь тенанта (активный, без хэша — для чтения).
@@ -37,7 +37,7 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
var tenantStore = new TestTenantStore(); var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var auditStore = new TestAuditLogStore(); var auditStore = new TestAuditLogStore();
@@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests
Assert.False(response.Headers.Contains("Set-Cookie")); Assert.False(response.Headers.Contains("Set-Cookie"));
}); });
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. // Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated.
StoredUserDto user = Assert.Single(authStore.Users); StoredUserDto user = Assert.Single(authStore.Users);
Assert.Equal(Email, user.Login); Assert.Equal(Email, user.Login);
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash); Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
@@ -80,7 +80,7 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
@@ -108,7 +108,7 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound() public async Task Join_WithUnknownCode_Returns400NotFound()
{ {
await RunAsync( await RunAsync(
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(), new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
await RunAsync( await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new TestAuditLogStore(), inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) => async (baseAddress, client) =>
{ {
using HttpResponseMessage response = await PostJsonAsync( using HttpResponseMessage response = await PostJsonAsync(
@@ -201,7 +201,7 @@ public sealed class JoinEndpointHttpTests
{ {
var inviteStore = new TestInviteStore(); var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email)); inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
await RunAsync( await RunAsync(
@@ -228,7 +228,7 @@ public sealed class JoinEndpointHttpTests
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var provisioner = new TestTenantProvisioner(); var provisioner = new TestTenantProvisioner();
var authStore = new FakeAuthStore(); var authStore = new TestAuthStore();
await RunAsync( await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(), inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
@@ -247,12 +247,12 @@ public sealed class JoinEndpointHttpTests
// ─── Хелперы ───────────────────────────────────────────────────────── // ─── Хелперы ─────────────────────────────────────────────────────────
// Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. // Поднимает in-process Kestrel с /api/join на подставках хранилищ и прогоняет сценарий.
private static async Task RunAsync( private static async Task RunAsync(
TestInviteStore inviteStore, TestInviteStore inviteStore,
TestTenantStore tenantStore, TestTenantStore tenantStore,
ITenantProvisioner provisioner, ITenantProvisioner provisioner,
FakeAuthStore authStore, TestAuthStore authStore,
TestAuditLogStore auditStore, TestAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario) Func<string, HttpClient, Task> scenario)
{ {
@@ -264,7 +264,7 @@ public sealed class JoinEndpointHttpTests
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает // Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
// последняя регистрация (зеркало OperatorAuthHttpHost). // последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore); builder.Services.AddSingleton<IAuthStore>(authStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store); builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store); builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository); builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
@@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests
new() { Enabled = true }; new() { Enabled = true };
// Фейк-хранилище пользователей с активным пользователем Login/Password. // Фейк-хранилище пользователей с активным пользователем Login/Password.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
Login, Login,
@@ -265,10 +265,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
return store; return store;
} }
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
return store; return store;
@@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests
} }
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
UserLogin, UserLogin,
@@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests
} }
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
UserLogin, UserLogin,
@@ -33,16 +33,16 @@ internal static class OperatorAuthHttpHost
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3"; "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
/// <summary> /// <summary>
/// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей. /// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей.
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param> /// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, Task> scenario,
TestAuditLogStore? auditStore = null) => TestAuditLogStore? auditStore = null) =>
await RunCoreAsync( await RunCoreAsync(
operatorStore, operatorStore,
@@ -56,15 +56,15 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param> /// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) => TestInviteStore? inviteStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -79,16 +79,16 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param> /// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param> /// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
public static async Task RunAsync( public static async Task RunAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) => TestTenantStore? tenantStore = null) =>
@@ -104,18 +104,18 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий /// Прогоняет сценарий
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param> /// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param> /// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param> /// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param> /// <param name="limitStore">Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param> /// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
public static async Task RunAsync( public static async Task RunAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null, TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null, TestTenantStore? tenantStore = null,
@@ -136,15 +136,15 @@ internal static class OperatorAuthHttpHost
/// <summary> /// <summary>
/// Прогоняет сценарий с глобальными настройками /// Прогоняет сценарий с глобальными настройками
/// </summary> /// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param> /// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param> /// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param> /// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param> /// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param> /// <param name="globalSettingsStore">Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
public static async Task RunWithGlobalSettingsAsync( public static async Task RunWithGlobalSettingsAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) => TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync( await RunCoreAsync(
@@ -160,12 +160,12 @@ internal static class OperatorAuthHttpHost
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки). // Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
private static async Task RunCoreAsync( private static async Task RunCoreAsync(
TestOperatorAuthStore operatorStore, TestOperatorAuthStore operatorStore,
FakeAuthStore userStore, TestAuthStore userStore,
TestAuditLogStore? auditStore, TestAuditLogStore? auditStore,
TestInviteStore? inviteStore, TestInviteStore? inviteStore,
TestTenantStore? tenantStore, TestTenantStore? tenantStore,
TestTenantLimitStore? limitStore, TestTenantLimitStore? limitStore,
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null, RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null, TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) TestGlobalSettingsStore? globalSettingsStore = null)
@@ -186,7 +186,7 @@ internal static class OperatorAuthHttpHost
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
builder.Services.AddSingleton(TestHashers.New()); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<ITenantContext, TenantContext>(); builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore); builder.Services.AddSingleton<IAuthStore>(userStore.Store);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store); builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store); builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store); builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
@@ -92,8 +92,8 @@ public sealed class OperatorHealthEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) => private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static TestOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
@@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
} }
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются). // Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
private static FakeAuthStore NewUserStore() => new(); private static TestAuthStore NewUserStore() => new();
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
private static HttpClient CreateClient(string baseAddress) => private static HttpClient CreateClient(string baseAddress) =>
@@ -284,7 +284,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit"; $"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null) private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
{ {
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
@@ -296,7 +296,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
return OperatorAuthHttpHost.RunAsync( return OperatorAuthHttpHost.RunAsync(
NewOperatorStore(), NewOperatorStore(),
new FakeAuthStore(), new TestAuthStore(),
scenario, scenario,
auditStore, auditStore,
tenantStore: tenantStore, tenantStore: tenantStore,
@@ -72,8 +72,8 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
} }
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) => private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static TestOperatorAuthStore NewOperatorStore() private static TestOperatorAuthStore NewOperatorStore()
@@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{baseAddress}/api/operator/settings/telegram-keys"; $"{baseAddress}/api/operator/settings/telegram-keys";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(Func<string, TestOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) => private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync( OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(), NewOperatorStore(),
new FakeAuthStore(), new TestAuthStore(),
scenario, scenario,
auditStore); auditStore);
@@ -486,7 +486,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync( private static Task RunAsync(
Func<string, TestOperatorAuthStore, FakeAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null, TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null) TestTenantStore? tenantStore = null)
{ {
@@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
} }
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте. // Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
private static FakeAuthStore NewUserStore() private static TestAuthStore NewUserStore()
{ {
var passwordHasher = TestHashers.New(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new TestAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
TenantUserLogin, TenantUserLogin,
@@ -0,0 +1,127 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAuthStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют пользователей и сессии через
/// <see cref="AddUser"/>, <see cref="AddSession"/>, <see cref="Users"/>, <see cref="Sessions"/>,
/// журнал мутирующих вызовов — <see cref="Calls"/>.
/// </summary>
public sealed class TestAuthStore
{
private readonly List<StoredUserDto> _users = [];
private readonly List<SessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Подставка порта аутентификации (создаётся в конструкторе).
/// </summary>
public IAuthStore Store { get; }
/// <summary>
/// Пользователи хранилища.
/// </summary>
public IReadOnlyList<StoredUserDto> Users => _users;
/// <summary>
/// Сессии хранилища.
/// </summary>
public IReadOnlyList<SessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
public TestAuthStore()
{
Store = Substitute.For<IAuthStore>();
Store.FindUserByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _users.SingleOrDefault(u => u.Login == ci.ArgAt<string>(0)));
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
Store.FindUserByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => FindById(ci.ArgAt<Guid>(0)));
Store.ListUsersByTenantIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => ListByTenantId(ci.ArgAt<Guid>(0)));
Store.When(s => s.CreateUserAsync(Arg.Any<StoredUserDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
StoredUserDto user = ci.Arg<StoredUserDto>();
_users.Add(user);
_calls.Add($"create-user:{user.Id}");
});
Store.When(s => s.CreateSessionAsync(Arg.Any<SessionDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
SessionDto session = ci.Arg<SessionDto>();
_sessions.Add(session);
_calls.Add($"create-session:{session.UserId}");
});
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
});
Store.When(s => s.DeleteSessionsByUserIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.UserId == ci.ArgAt<Guid>(0));
_calls.Add($"delete-user-sessions:{ci.ArgAt<Guid>(0)}");
});
Store.When(s => s.UpdatePasswordHashAsync(
Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdatePasswordHash(ci.ArgAt<Guid>(0), ci.ArgAt<string>(1)));
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
.Do(_ =>
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
});
}
/// <summary>
/// Добавляет пользователя
/// </summary>
/// <param name="user">Пользователь.</param>
public void AddUser(StoredUserDto user) => _users.Add(user);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(SessionDto session) => _sessions.Add(session);
private UserIdentityDto? FindById(Guid userId)
{
StoredUserDto? user = _users.SingleOrDefault(u => u.Id == userId);
return user is null ? null : new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status);
}
private IReadOnlyList<UserIdentityDto> ListByTenantId(Guid tenantId)
{
return _users
.Where(u => u.TenantId == tenantId)
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
.ToList();
}
private void UpdatePasswordHash(Guid userId, string passwordHash)
{
int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0)
{
StoredUserDto user = _users[index];
_users[index] = user with { PasswordHash = passwordHash };
}
_calls.Add($"update-password-hash:{userId}");
}
}