Files
Wino-Mail/Wino.Core.Tests/Services/MailFetchingTests.cs
T
Burak Kaan Köse ebc35c3de8 Event creation.
2026-03-07 17:13:48 +01:00

384 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using FluentAssertions;
using Moq;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Tests.Helpers;
using Wino.Services;
using Xunit;
using Xunit.Abstractions;
namespace Wino.Core.Tests.Services;
/// <summary>
/// Integration tests for MailService.FetchMailsAsync that verify the correctness of
/// thread expansion and contact resolution, and track performance for large inboxes.
///
/// All tests run against a real in-memory SQLite file via the full service stack
/// (MailService → FolderService / AccountService / ContactService) so that the
/// batch-query path introduced in the performance optimisation is exercised end-to-end.
/// </summary>
public class MailFetchingTests : IAsyncLifetime
{
// ── Infrastructure ─────────────────────────────────────────────────────────
private readonly ITestOutputHelper _output;
private InMemoryDatabaseService _databaseService = null!;
private MailService _mailService = null!;
private MailAccount _testAccount = null!;
private MailItemFolder _inboxFolder = null!;
public MailFetchingTests(ITestOutputHelper output)
{
_output = output;
}
public async Task InitializeAsync()
{
_databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync();
_testAccount = new MailAccount
{
Id = Guid.NewGuid(),
Name = "Test Account",
Address = "me@test.local",
SenderName = "Test User",
ProviderType = MailProviderType.IMAP4
};
_inboxFolder = new MailItemFolder
{
Id = Guid.NewGuid(),
MailAccountId = _testAccount.Id,
FolderName = "Inbox",
SpecialFolderType = SpecialFolderType.Inbox,
IsSystemFolder = true,
IsSynchronizationEnabled = true
};
await _databaseService.Connection.InsertAsync(_testAccount, typeof(MailAccount));
await _databaseService.Connection.InsertAsync(_inboxFolder, typeof(MailItemFolder));
_mailService = BuildMailService(_databaseService);
}
public async Task DisposeAsync() => await _databaseService.DisposeAsync();
// ── Correctness: threading ON ──────────────────────────────────────────────
/// <summary>
/// Verifies that thread siblings which fall outside the initial SQL page are
/// fetched by the expansion step, so every thread is always fully represented.
///
/// Setup: 2 threads of 3 mails each (6 mails total), page size = 4.
/// The main query retrieves Thread A (3 mails, newest) and Thread B mail 1 (position 4).
/// Thread expansion must then fetch Thread B mails 2-3 that were beyond the page.
/// </summary>
[Fact]
public async Task FetchMailsAsync_WithThreadingEnabled_ExpandsSiblingsOutsidePage()
{
const int PageSize = 4;
var threadA = Guid.NewGuid().ToString();
var threadB = Guid.NewGuid().ToString();
var baseDate = DateTime.UtcNow;
var mails = new List<MailCopy>
{
// Thread A all 3 land within the first page (positions 13)
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-1), threadId: threadA),
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-2), threadId: threadA),
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-3), threadId: threadA),
// Thread B only position 4 lands in the page; 5 and 6 must be expanded
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-4), threadId: threadB),
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-5), threadId: threadB),
BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-6), threadId: threadB)
};
await _databaseService.Connection.InsertAllAsync(mails, typeof(MailCopy));
var options = BuildOptions([_inboxFolder], createThreads: true, take: PageSize);
// Act
var result = await _mailService.FetchMailsAsync(options);
// Assert all 6 mails returned even though the page only held 4
result.Should().HaveCount(6,
"the 2 Thread B siblings outside the initial page must be fetched by expansion");
result.Should().OnlyContain(m => m.AssignedAccount != null && m.AssignedFolder != null,
"every returned mail must have its account and folder resolved");
result.Count(m => m.ThreadId == threadA).Should().Be(3, "Thread A must be complete");
result.Count(m => m.ThreadId == threadB).Should().Be(3, "Thread B must be complete");
}
// ── Correctness: threading OFF ─────────────────────────────────────────────
/// <summary>
/// Verifies that when threading is disabled the result exactly matches the raw
/// SQL page — no sibling expansion occurs.
/// </summary>
[Fact]
public async Task FetchMailsAsync_WithThreadingDisabled_NeverExpandsSiblings()
{
const int PageSize = 4;
var threadId = Guid.NewGuid().ToString();
var baseDate = DateTime.UtcNow;
// 6 mails all sharing a ThreadId; with threading OFF only the first 4 come back
var mails = Enumerable.Range(0, 6)
.Select(i => BuildMail(_inboxFolder.Id, baseDate.AddSeconds(-i), threadId: threadId))
.ToList();
await _databaseService.Connection.InsertAllAsync(mails, typeof(MailCopy));
var options = BuildOptions([_inboxFolder], createThreads: false, take: PageSize);
// Act
var result = await _mailService.FetchMailsAsync(options);
// Assert exactly the page size; no expansion happened
result.Should().HaveCount(PageSize,
"with threading disabled the result must match the raw page size");
result.Should().OnlyContain(m => m.AssignedAccount != null && m.AssignedFolder != null);
}
// ── Correctness: contact resolution ───────────────────────────────────────
/// <summary>
/// Verifies that sender contacts are resolved from three distinct paths:
/// the contact store (known sender), the unknown-sender fallback, and the
/// account-metadata shortcut used for self-sent mails.
/// </summary>
[Fact]
public async Task FetchMailsAsync_SenderContact_ResolvesFromAllThreeSources()
{
const string KnownAddress = "known@example.com";
const string UnknownAddress = "unknown@example.com";
await _databaseService.Connection.InsertAsync(
new AccountContact { Address = KnownAddress, Name = "Known Sender" },
typeof(AccountContact));
var mails = new List<MailCopy>
{
BuildMail(_inboxFolder.Id, DateTime.UtcNow, fromAddress: KnownAddress),
BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddSeconds(-1), fromAddress: UnknownAddress),
BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddSeconds(-2), fromAddress: _testAccount.Address)
};
await _databaseService.Connection.InsertAllAsync(mails, typeof(MailCopy));
var options = BuildOptions([_inboxFolder], createThreads: false, take: 10);
// Act
var result = await _mailService.FetchMailsAsync(options);
result.Should().HaveCount(3);
// Known contact resolved from AccountContact table
var knownResult = result.Single(m => m.FromAddress == KnownAddress);
knownResult.SenderContact!.Name.Should().Be("Known Sender");
// Unknown address falls back to an ad-hoc contact built from From headers
var unknownResult = result.Single(m => m.FromAddress == UnknownAddress);
unknownResult.SenderContact!.Address.Should().Be(UnknownAddress);
// Self-sent mail contact built from account metadata, not the contact store
var selfResult = result.Single(m => m.FromAddress == _testAccount.Address);
selfResult.SenderContact!.Name.Should().Be(_testAccount.SenderName,
"self-sent mail must use account metadata for the sender contact");
}
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
/// <summary>
/// Creates 1 000 mails: 70 threads of 7 mails each (490 mails) plus 510 standalone.
/// The mails are ordered newest-first in thread blocks so the default first-page
/// fetch (100 mails) naturally spans several complete threads and the tail of one
/// partial thread, letting us observe thread expansion.
///
/// Two scenarios are measured and written to test output:
/// 1. First-page fetch (100 mails) plus automatic thread expansion.
/// 2. Full load of all 1 000 mails with threading enabled.
///
/// A generous 5-second budget is asserted to catch catastrophic regressions
/// without being brittle on slow CI hardware.
/// </summary>
[Fact]
public async Task FetchMailsAsync_1000Mails_70Threads_CompletesWithinBudget()
{
// ── Arrange ────────────────────────────────────────────────────────────
const int ThreadCount = 70;
const int MailsPerThread = 7;
const int TotalMails = 1_000;
const int StandaloneMails = TotalMails - (ThreadCount * MailsPerThread); // 510
// 40 rotating sender addresses; the first 20 have entries in the contact store.
var senders = Enumerable.Range(0, 40)
.Select(i => $"sender{i:D2}@example.com")
.ToList();
var knownContacts = senders.Take(20)
.Select((addr, i) => new AccountContact { Address = addr, Name = $"Sender {i}" })
.ToList();
await _databaseService.Connection.InsertAllAsync(knownContacts, typeof(AccountContact));
// Threads occupy the newest date slots (positions 0489) so the default 100-mail
// page always intersects several threads, triggering sibling expansion.
var mails = new List<MailCopy>(TotalMails);
var baseDate = DateTime.UtcNow;
int slot = 0;
for (int t = 0; t < ThreadCount; t++)
{
var threadId = Guid.NewGuid().ToString();
for (int m = 0; m < MailsPerThread; m++)
{
mails.Add(BuildMail(
_inboxFolder.Id,
baseDate.AddSeconds(-slot),
threadId: threadId,
fromAddress: senders[slot % senders.Count]));
slot++;
}
}
for (int i = 0; i < StandaloneMails; i++)
{
mails.Add(BuildMail(
_inboxFolder.Id,
baseDate.AddSeconds(-slot),
fromAddress: senders[slot % senders.Count]));
slot++;
}
await _databaseService.Connection.InsertAllAsync(mails, typeof(MailCopy));
_output.WriteLine($"Inserted {TotalMails} mails — " +
$"{ThreadCount} threads × {MailsPerThread} mails + {StandaloneMails} standalone");
_output.WriteLine(string.Empty);
// ── Scenario 1: first page (default 100) + thread expansion ───────────
// The 100 newest mails span threads 013 completely (14 × 7 = 98 mails) plus
// the first 2 mails of thread 14. Expansion must fetch thread 14's 5 siblings.
var optionsPage = BuildOptions([_inboxFolder], createThreads: true);
var sw = Stopwatch.StartNew();
var pageResult = await _mailService.FetchMailsAsync(optionsPage);
sw.Stop();
long pageMs = sw.ElapsedMilliseconds;
_output.WriteLine("[Scenario 1 first page + thread expansion]");
_output.WriteLine($" Mails returned : {pageResult.Count} (expected > 100)");
_output.WriteLine($" Elapsed : {pageMs} ms");
_output.WriteLine(string.Empty);
pageResult.Should().OnlyContain(m => m.AssignedAccount != null && m.AssignedFolder != null);
// Thread expansion must have added thread 14's 5 siblings beyond the 100-mail page.
pageResult.Count.Should().BeGreaterThan(100,
"thread expansion must pull in siblings that were beyond the initial 100-mail page");
// ── Scenario 2: full load of all 1 000 mails with threading ───────────
var optionsAll = BuildOptions([_inboxFolder], createThreads: true, take: TotalMails);
sw.Restart();
var allResult = await _mailService.FetchMailsAsync(optionsAll);
sw.Stop();
long allMs = sw.ElapsedMilliseconds;
_output.WriteLine($"[Scenario 2 full load ({TotalMails} mails, threading enabled)]");
_output.WriteLine($" Mails returned : {allResult.Count} (expected {TotalMails})");
_output.WriteLine($" Elapsed : {allMs} ms");
allResult.Should().HaveCount(TotalMails,
"every mail must be returned when Take equals the total count");
allResult.Should().OnlyContain(m => m.AssignedAccount != null && m.AssignedFolder != null);
// All 70 threads must be intact in the full result.
var threadGroups = allResult
.Where(m => !string.IsNullOrEmpty(m.ThreadId))
.GroupBy(m => m.ThreadId!)
.ToList();
threadGroups.Should().HaveCount(ThreadCount,
"all 70 threads must be represented in the full load");
threadGroups.Should().OnlyContain(g => g.Count() == MailsPerThread,
"every thread must contain exactly the expected number of mails");
allMs.Should().BeLessThan(5_000,
$"fetching {TotalMails} threaded mails via batched SQLite queries should complete well under 5 s");
}
// ── Helpers ────────────────────────────────────────────────────────────────
private static MailCopy BuildMail(
Guid folderId,
DateTime creationDate,
string? threadId = null,
string fromAddress = "external@example.com")
{
return new MailCopy
{
UniqueId = Guid.NewGuid(),
Id = Guid.NewGuid().ToString(),
FileId = Guid.NewGuid(),
FolderId = folderId,
Subject = $"Subject {Guid.NewGuid():N}",
PreviewText = "Preview text",
FromAddress = fromAddress,
FromName = fromAddress.Split('@')[0],
CreationDate = creationDate,
ThreadId = threadId,
IsRead = false
};
}
private static MailListInitializationOptions BuildOptions(
IEnumerable<MailItemFolder> folders,
bool createThreads = true,
int take = 0)
{
return new MailListInitializationOptions(
Folders: folders,
FilterType: FilterOptionType.All,
SortingOptionType: SortingOptionType.ReceiveDate,
CreateThreads: createThreads,
IsFocusedOnly: null,
SearchQuery: null,
Take: take);
}
/// <summary>
/// Builds a MailService wired to real FolderService, AccountService, and ContactService
/// all backed by the shared in-memory database, so the full SQL batch path is exercised.
/// </summary>
private static MailService BuildMailService(InMemoryDatabaseService db)
{
var signatureService = new Mock<ISignatureService>();
var authProvider = new Mock<IAuthenticationProvider>();
var mimeFileService = new Mock<IMimeFileService>();
var preferencesService = new Mock<IPreferencesService>();
var contactPictureFileService = new Mock<IContactPictureFileService>();
var accountService = new AccountService(
db,
signatureService.Object,
authProvider.Object,
mimeFileService.Object,
preferencesService.Object,
contactPictureFileService.Object);
var folderService = new FolderService(db, accountService);
var contactService = new ContactService(db);
return new MailService(
db,
folderService,
contactService,
accountService,
signatureService.Object,
mimeFileService.Object,
preferencesService.Object);
}
}