Files
Wino-Mail/Wino.Core.Tests/Services/MailFetchingTests.cs
T
Burak Kaan Köse 2040d4abce Optimize mail fetching with batch DB queries and in-memory caching (#827)
* perf: batch-load folders, accounts, and contacts in FetchMailsAsync

Replace the sequential per-mail property-loading loop with a three-step
batch pre-load strategy, eliminating the N+1 DB call pattern that was
the main bottleneck when building the mail list with threading enabled.

Changes:
- Pre-seed the folder cache from MailListInitializationOptions.Folders
  so that the most common folders (inbox, sent, etc.) never trigger a DB
  lookup at all.
- Load all accounts in a single GetAccountsAsync() call instead of one
  GetAccountAsync() call per mail (typically 1–5 accounts total).
- Fetch all sender contacts in a single SQL IN(...) query via the new
  GetContactsByAddressesAsync() method instead of one query per address.
- Property assignment is now fully synchronous (no awaits in the loop)
  since all data is pre-loaded into plain Dictionary<K,V>.
- Thread-expansion follows the same pattern: new folder IDs are loaded
  in parallel via Task.WhenAll; new contact addresses are batch-fetched
  with a second IN(...) query.
- Also apply batch pre-loading to GetMailItemsAsync (used by merge-inbox
  sync path) which had the same sequential issue.
- Remove the now-unused LoadAssignedPropertiesWithCacheAsync helper and
  the ConcurrentDictionary dependency it required.
- Tighten GetMailsByThreadIdsAsync to skip the Id NOT IN clause entirely
  when the exclusion set is empty.

https://claude.ai/code/session_018bqahGc6zi95JJhc2aARKS

* test: add MailFetchingTests with correctness and performance coverage

Adds integration tests for MailService.FetchMailsAsync that exercise the
full real-service stack (MailService → FolderService / AccountService /
ContactService) backed by the shared in-memory SQLite helper.

Four tests are included:

• ExpandsSiblingsOutsidePage – proves thread expansion fetches mails that
  fall beyond the initial SQL page (6 mails, page=4, expects 6 returned).

• NeverExpandsSiblings – proves threading is truly opt-in; with
  CreateThreads=false the result exactly matches the raw page size.

• ResolvesFromAllThreeSources – verifies contact resolution for a known
  contact (from the AccountContact table), an unknown sender (ad-hoc
  fallback), and a self-sent mail (built from account metadata).

• 1000Mails_70Threads_CompletesWithinBudget – the performance scenario:
  1 000 mails (70 threads × 7 + 510 standalone), 40 rotating sender
  addresses (20 with DB contacts). Times and reports two scenarios:
    - Default first-page fetch (100 mails) + expansion of one partial
      thread (expects > 100 mails returned).
    - Full load of all 1 000 mails with threading enabled (expects
      exactly 1 000 mails returned, all 70 threads intact, < 5 s).

  Elapsed times for both scenarios are written to xUnit test output so
  they appear in CI logs and can be tracked across builds.

https://claude.ai/code/session_018bqahGc6zi95JJhc2aARKS

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-01 09:14:02 +01:00

382 lines
16 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 accountService = new AccountService(
db,
signatureService.Object,
authProvider.Object,
mimeFileService.Object,
preferencesService.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);
}
}