[CLAUDE] Tests: Chunk E6 — 6 test 2-stage approval (PE) + IdentityFixture helper
Đóng "Tests Phase 3 mini cần UserManager DI helper" defer từ session 8. IdentityFixture (Common/IdentityFixture.cs): - Setup ServiceProvider với Identity stack đầy đủ: - DbContext SQLite shared connection - AddIdentityCore<User> + AddRoles<Role> + AddEntityFrameworkStores - Single shared scope cho fixture lifetime → DbContext + UserManager đồng instance - Helper CreateUserAsync(email, name, deptId, roles, canBypassReview) - Note: dùng Role custom (không phải IdentityRole<Guid>) để match ApplicationDbContext : IdentityDbContext<User, Role, Guid> 6 test PE 2-stage logic (Services/PeTwoStageApprovalTests.cs): - NV_Review_Blocks_Phase_Transition (đóng bug anh Kiệt — chính xác) - TPB_Confirm_After_NV_Review_Allows_Transition (happy path 2-stage) - NV_With_BypassReview_Allows_Transition_With_IsBypassed_True (bypass NV) - Admin_Skips_TwoStage_Logic_Entirely (admin bypass) - Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao (smart reject) - Resume_After_Reject_Jumps_Back_To_RejectedPhase (jump-back logic) Stub FakeNotificationService — best effort path không cần verify. Note: tests cho Contract + Budget 2-stage skip — logic identical PE, ROI thấp. Pattern PeTwoStageApprovalTests reusable nếu cần test riêng tương lai. Total: 54 Domain + 29 Infra (17 codegen + 6 PE WF Application + 6 PE 2-stage) = **83 test pass** (+6 mới). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
113
tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs
Normal file
113
tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
// Identity-aware fixture cho tests cần UserManager + RoleManager.
|
||||||
|
//
|
||||||
|
// Tại sao tách khỏi SqliteDbFixture?
|
||||||
|
// - SqliteDbFixture chỉ EF + DbContext (đủ cho code generator tests).
|
||||||
|
// - Service tests cần Identity stack: UserManager.FindByIdAsync, GetRolesAsync,
|
||||||
|
// CreateAsync, AddToRolesAsync — phụ thuộc IUserStore + IRoleStore + hashers
|
||||||
|
// + validators registered qua AddIdentityCore + AddRoles + AddEntityFrameworkStores.
|
||||||
|
// - Single connection mỗi fixture → DB persists across UserManager.Save calls.
|
||||||
|
//
|
||||||
|
// Pattern: ServiceProvider build từ AddIdentityCore. Test gọi GetRequired để
|
||||||
|
// resolve UserManager, DbContext, etc. EnsureCreated() build schema từ model
|
||||||
|
// (skip migrations vì test isolated).
|
||||||
|
public sealed class IdentityFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _root;
|
||||||
|
public IServiceProvider Services { get; } // scoped to single test scope (shared across tests in class)
|
||||||
|
|
||||||
|
public IdentityFixture()
|
||||||
|
{
|
||||||
|
_connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_connection.Open();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
|
||||||
|
// Manual options + factory để inject TestApplicationDbContext qua type
|
||||||
|
// ApplicationDbContext (Identity EF stores expect base type).
|
||||||
|
var connection = _connection;
|
||||||
|
services.AddScoped<ApplicationDbContext>(_ =>
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.Options;
|
||||||
|
return new TestApplicationDbContext(options);
|
||||||
|
});
|
||||||
|
services.AddScoped<TestApplicationDbContext>(sp =>
|
||||||
|
(TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
|
||||||
|
services.AddScoped<IApplicationDbContext>(sp =>
|
||||||
|
sp.GetRequiredService<TestApplicationDbContext>());
|
||||||
|
|
||||||
|
services.AddIdentityCore<User>(opt =>
|
||||||
|
{
|
||||||
|
opt.Password.RequireDigit = false;
|
||||||
|
opt.Password.RequireLowercase = false;
|
||||||
|
opt.Password.RequireUppercase = false;
|
||||||
|
opt.Password.RequireNonAlphanumeric = false;
|
||||||
|
opt.Password.RequiredLength = 4;
|
||||||
|
})
|
||||||
|
.AddRoles<Role>()
|
||||||
|
.AddEntityFrameworkStores<ApplicationDbContext>();
|
||||||
|
|
||||||
|
_root = services.BuildServiceProvider();
|
||||||
|
Services = _root.CreateScope().ServiceProvider;
|
||||||
|
|
||||||
|
var ctx = Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: tạo user + assign roles + gán DepartmentId. Reuse trong nhiều test.
|
||||||
|
public async Task<User> CreateUserAsync(
|
||||||
|
string email,
|
||||||
|
string fullName,
|
||||||
|
Guid? departmentId,
|
||||||
|
string[] roles,
|
||||||
|
bool canBypassReview = false)
|
||||||
|
{
|
||||||
|
var um = Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var rm = Services.GetRequiredService<RoleManager<Role>>();
|
||||||
|
|
||||||
|
// Ensure roles exist (idempotent).
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
if (!await rm.RoleExistsAsync(role))
|
||||||
|
await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = fullName,
|
||||||
|
DepartmentId = departmentId,
|
||||||
|
CanBypassReview = canBypassReview,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
var created = await um.CreateAsync(user, "Test@123");
|
||||||
|
if (!created.Succeeded)
|
||||||
|
throw new InvalidOperationException("CreateUserAsync failed: " + string.Join(",", created.Errors.Select(e => e.Description)));
|
||||||
|
if (roles.Length > 0)
|
||||||
|
await um.AddToRolesAsync(user, roles);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_root.Dispose();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,257 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// Tests cho 2-stage department approval logic ở PurchaseEvaluationWorkflowService.
|
||||||
|
// Cover bug fix anh Kiệt: NV.PRO duyệt phase ChoPurchasing → BLOCK transition.
|
||||||
|
// TPB.PRO confirm → ALLOW transition.
|
||||||
|
//
|
||||||
|
// Pattern: dùng IdentityFixture (Identity stack + DbContext SQLite) để
|
||||||
|
// test thật end-to-end service thay vì mock.
|
||||||
|
public class PeTwoStageApprovalTests : IClassFixture<IdentityFixture>
|
||||||
|
{
|
||||||
|
private readonly IdentityFixture _fx;
|
||||||
|
private readonly TestApplicationDbContext _db;
|
||||||
|
private readonly UserManager<User> _userManager;
|
||||||
|
private readonly PurchaseEvaluationWorkflowService _service;
|
||||||
|
private readonly Guid _deptPro;
|
||||||
|
private readonly Guid _deptCcm;
|
||||||
|
|
||||||
|
public PeTwoStageApprovalTests(IdentityFixture fx)
|
||||||
|
{
|
||||||
|
_fx = fx;
|
||||||
|
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
|
||||||
|
// Seed 2 departments (idempotent — check trước khi insert vì fixture
|
||||||
|
// shared across tests trong class).
|
||||||
|
_deptPro = SeedDept("PRO", "Phòng Cung ứng");
|
||||||
|
_deptCcm = SeedDept("CCM", "Phòng Kiểm soát chi phí");
|
||||||
|
|
||||||
|
var clock = new FixedDateTime(new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc));
|
||||||
|
var fakeNotifications = new FakeNotificationService();
|
||||||
|
|
||||||
|
_service = new PurchaseEvaluationWorkflowService(
|
||||||
|
_db,
|
||||||
|
clock,
|
||||||
|
fakeNotifications,
|
||||||
|
_userManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid SeedDept(string code, string name)
|
||||||
|
{
|
||||||
|
var existing = _db.Departments.FirstOrDefault(d => d.Code == code);
|
||||||
|
if (existing is not null) return existing.Id;
|
||||||
|
var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name };
|
||||||
|
_db.Departments.Add(d);
|
||||||
|
_db.SaveChanges();
|
||||||
|
return d.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PurchaseEvaluation> SeedPeAsync(PurchaseEvaluationPhase phase, Guid? projectId = null)
|
||||||
|
{
|
||||||
|
// Project required by FK constraint.
|
||||||
|
var pid = projectId ?? Guid.NewGuid();
|
||||||
|
if (!_db.Projects.Any(p => p.Id == pid))
|
||||||
|
{
|
||||||
|
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
||||||
|
{
|
||||||
|
Id = pid,
|
||||||
|
Code = $"PRJ-{Random.Shared.Next(10000):D4}",
|
||||||
|
Name = "Test project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = phase,
|
||||||
|
TenGoiThau = "Test gói thầu",
|
||||||
|
ProjectId = pid,
|
||||||
|
};
|
||||||
|
_db.PurchaseEvaluations.Add(pe);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NV_Review_Blocks_Phase_Transition()
|
||||||
|
{
|
||||||
|
// Arrange: NV.PRO (role Procurement, dept PRO, NOT DeptManager).
|
||||||
|
var nv = await _fx.CreateUserAsync(
|
||||||
|
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
|
||||||
|
// Act: NV approve to ChoCCM.
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "review");
|
||||||
|
|
||||||
|
// Assert: phase KHÔNG đổi, có 1 row Stage=Review.
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
|
||||||
|
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
deptApprovals.Should().HaveCount(1);
|
||||||
|
deptApprovals[0].Stage.Should().Be(ApprovalStage.Review);
|
||||||
|
deptApprovals[0].DepartmentId.Should().Be(_deptPro);
|
||||||
|
deptApprovals[0].IsBypassed.Should().BeFalse();
|
||||||
|
|
||||||
|
var approvals = await _db.PurchaseEvaluationApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1);
|
||||||
|
approvals[0].Comment.Should().StartWith("[Review NV]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TPB_Confirm_After_NV_Review_Allows_Transition()
|
||||||
|
{
|
||||||
|
// Arrange: NV review trước, sau đó TPB confirm.
|
||||||
|
var nv = await _fx.CreateUserAsync(
|
||||||
|
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
||||||
|
var tpb = await _fx.CreateUserAsync(
|
||||||
|
$"tpb-{Guid.NewGuid():N}@test", "TPB PRO", _deptPro, ["DeptManager", "Procurement"]);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "review NV");
|
||||||
|
|
||||||
|
// Re-fetch tracked entity (service modifies state ở Phase prior).
|
||||||
|
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
||||||
|
|
||||||
|
// Act: TPB confirm.
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoCCM, tpb.Id, ["DeptManager", "Procurement"],
|
||||||
|
ApprovalDecision.Approve, "confirm TPB");
|
||||||
|
|
||||||
|
// Assert: phase đổi, có 2 row (Review + Confirm).
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
|
||||||
|
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
deptApprovals.Should().HaveCount(2);
|
||||||
|
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Review && a.ApproverUserId == nv.Id);
|
||||||
|
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Confirm && a.ApproverUserId == tpb.Id && !a.IsBypassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NV_With_BypassReview_Allows_Transition_With_IsBypassed_True()
|
||||||
|
{
|
||||||
|
// Arrange: NV CanBypassReview=true.
|
||||||
|
var nv = await _fx.CreateUserAsync(
|
||||||
|
$"nv-bypass-{Guid.NewGuid():N}@test", "NV PRO bypass",
|
||||||
|
_deptPro, ["Procurement"], canBypassReview: true);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
|
||||||
|
// Act: bypass user approve → đẩy thẳng Stage=Confirm.
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "bypass approve");
|
||||||
|
|
||||||
|
// Assert: phase đổi, có 1 row Stage=Confirm + IsBypassed=true.
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
|
||||||
|
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
deptApprovals.Should().HaveCount(1);
|
||||||
|
deptApprovals[0].Stage.Should().Be(ApprovalStage.Confirm);
|
||||||
|
deptApprovals[0].IsBypassed.Should().BeTrue();
|
||||||
|
deptApprovals[0].ApproverRoleSnapshot.Should().Be("NV(bypass)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_Skips_TwoStage_Logic_Entirely()
|
||||||
|
{
|
||||||
|
// Arrange: Admin role.
|
||||||
|
var admin = await _fx.CreateUserAsync(
|
||||||
|
$"admin-{Guid.NewGuid():N}@test", "Admin user", null, ["Admin"]);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
|
||||||
|
// Act: Admin approve.
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoCCM, admin.Id, ["Admin"],
|
||||||
|
ApprovalDecision.Approve, "admin force");
|
||||||
|
|
||||||
|
// Assert: phase đổi, KHÔNG có DepartmentApproval row.
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
|
||||||
|
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||||
|
deptApprovals.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao()
|
||||||
|
{
|
||||||
|
// Arrange: PE phase=ChoCCM. Drafter reject.
|
||||||
|
var actor = await _fx.CreateUserAsync(
|
||||||
|
$"ccm-{Guid.NewGuid():N}@test", "CCM TPB", _deptCcm, ["DeptManager", "CostControl"]);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
|
||||||
|
// Act: reject (target irrelevant — service forces về DangSoanThao).
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.TuChoi, actor.Id, ["DeptManager", "CostControl"],
|
||||||
|
ApprovalDecision.Reject, "không phù hợp");
|
||||||
|
|
||||||
|
// Assert.
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
||||||
|
fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resume_After_Reject_Jumps_Back_To_RejectedPhase()
|
||||||
|
{
|
||||||
|
// Arrange: PE rejected từ ChoCCM, đang ở DangSoanThao + RejectedFromPhase=ChoCCM.
|
||||||
|
var drafter = await _fx.CreateUserAsync(
|
||||||
|
$"drafter-{Guid.NewGuid():N}@test", "Drafter", _deptPro, ["Drafter"]);
|
||||||
|
var pe = await SeedPeAsync(PurchaseEvaluationPhase.DangSoanThao);
|
||||||
|
pe.RejectedFromPhase = PurchaseEvaluationPhase.ChoCCM;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act: drafter trình lại từ DangSoanThao → ChoPurchasing (target không
|
||||||
|
// quan trọng vì resume sẽ override = RejectedFromPhase). Note: service
|
||||||
|
// jump tới ChoCCM, nhưng nếu actor có dept thì sẽ hit 2-stage logic.
|
||||||
|
// Simpler: dùng admin để bypass 2-stage gate khi resume cũng OK.
|
||||||
|
var admin = await _fx.CreateUserAsync(
|
||||||
|
$"admin-resume-{Guid.NewGuid():N}@test", "Admin resume", null, ["Admin"]);
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoPurchasing, admin.Id, ["Admin"],
|
||||||
|
ApprovalDecision.Approve, "drafter resume");
|
||||||
|
|
||||||
|
// Assert: phase jump tới ChoCCM (không phải ChoPurchasing target),
|
||||||
|
// RejectedFromPhase=null.
|
||||||
|
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||||
|
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
fresh.RejectedFromPhase.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub notification service — tests không cần verify notification path
|
||||||
|
// (best effort try/catch ở service đã cover fail case).
|
||||||
|
internal class FakeNotificationService : INotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyAsync(Guid userId, NotificationType type, string title,
|
||||||
|
string? description = null, string? href = null, Guid? refId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyManyAsync(IEnumerable<Guid> userIds, NotificationType type,
|
||||||
|
string title, string? description = null, string? href = null,
|
||||||
|
Guid? refId = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user