Files
AI-Studio/app/Tests/Tools/Mail/MailboxRetrievalTests.cs
T
Thorsten Sommer c4400c0ff6
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Added mailboxes as local data sources (#1022)
2026-10-04 12:26:26 +02:00

220 lines
11 KiB
C#

using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tests.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools.Mail;
/// <summary>
/// Checks which mailboxes the mail tools may read, and what they get from the index.
/// </summary>
/// <remarks>
/// A mailbox holds what its owner wrote and received, so a provider below its level must never see
/// a word of it: neither the provider of the chat, which reads what is found, nor the embedding
/// provider, which gets the query. Within a mailbox, the tools rely on pages which neither repeat
/// nor skip a mail, and on a reply leading to the mail it answers.
/// </remarks>
[TestFixture]
public sealed class MailboxRetrievalTests
{
private const string MAILBOX = "3d8f1a6c-9b2e-4c7d-a5f0-6e1b8c4d2a97";
private const string OTHER_MAILBOX = "8b2c6e1f-4a9d-4f3b-b7e5-1c0d9a6f3e82";
private const string INBOX = "INBOX";
private static readonly CancellationToken TOKEN = CancellationToken.None;
private static readonly DataSourceMailbox WORK = new() { Id = MAILBOX, Name = "Work", MaxAge = MailboxMaxAge.LAST_6_MONTHS };
private static readonly DataSourceMailbox PRIVATE = new() { Id = OTHER_MAILBOX, Name = "Private", MaxAge = MailboxMaxAge.ALL };
private TemporaryIndexStore store = null!;
private string question = string.Empty;
private string answer = string.Empty;
private string newsletter = string.Empty;
private string deleted = string.Empty;
private string elsewhere = string.Empty;
[SetUp]
public async Task CreateMailboxesAsync()
{
this.store = await TemporaryIndexStore.CreateAsync();
foreach (var mailboxId in new[] { MAILBOX, OTHER_MAILBOX })
{
await this.store.Client.UpsertDataSourceAsync(mailboxId, "MAILBOX", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
await this.store.Client.UpsertMailFolderAsync(mailboxId, new MailFolderRecord(INBOX, MailFolderSpecialUse.NONE, 1, null, null, 120, 4, null), TOKEN);
}
this.question = await this.store.StoreMailAsync(MAILBOX, "question", "Question", "Could you have a look at the draft?", Mail(new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero), At(1, isSeen: true)) with { MessageId = "question@example.org" });
this.answer = await this.store.StoreMailAsync(MAILBOX, "answer", "Re: Question", "I had a look, see my comments.", Mail(new DateTimeOffset(2026, 9, 10, 9, 30, 0, TimeSpan.Zero), At(2, isSeen: false)) with { MessageId = "answer@example.org", InReplyTo = "question@example.org" });
this.newsletter = await this.store.StoreMailAsync(MAILBOX, "newsletter", "Newsletter August", "What happened in August.", Mail(new DateTimeOffset(2026, 8, 15, 7, 0, 0, TimeSpan.Zero), At(3, isSeen: true)));
this.deleted = await this.store.StoreMailAsync(MAILBOX, "deleted", "Old draft", "A mail which is gone from the server.", Mail(new DateTimeOffset(2026, 9, 20, 7, 0, 0, TimeSpan.Zero), At(4, isSeen: false)));
await this.store.Client.RemoveMailLocationsAsync(MAILBOX, INBOX, [4], TOKEN);
this.elsewhere = await this.store.StoreMailAsync(OTHER_MAILBOX, "elsewhere", "Holidays", "A mail in another mailbox.", Mail(new DateTimeOffset(2026, 9, 25, 7, 0, 0, TimeSpan.Zero), At(1, isSeen: false)));
}
[TearDown]
public async Task DeleteMailboxesAsync() => await this.store.DisposeAsync();
[TestCase(ConfidenceLevel.MEDIUM, ConfidenceLevel.HIGH, ConfidenceLevel.HIGH, true)]
[TestCase(ConfidenceLevel.MEDIUM, ConfidenceLevel.MEDIUM, ConfidenceLevel.MEDIUM, true)]
[TestCase(ConfidenceLevel.MEDIUM, ConfidenceLevel.LOW, ConfidenceLevel.HIGH, false)]
[TestCase(ConfidenceLevel.MEDIUM, ConfidenceLevel.HIGH, ConfidenceLevel.LOW, false)]
public void BothProvidersHaveToMeetTheLevelOfTheMailbox(ConfidenceLevel mailboxLevel, ConfidenceLevel chatProviderConfidence, ConfidenceLevel embeddingProviderConfidence, bool expected)
{
Assert.That(MailboxRetrievalService.IsReadable(mailboxLevel, chatProviderConfidence, embeddingProviderConfidence), Is.EqualTo(expected));
}
[TestCase(ConfidenceLevel.NONE)]
[TestCase(ConfidenceLevel.UNTRUSTED)]
[TestCase(ConfidenceLevel.UNKNOWN)]
public void AMailboxWithoutAValidLevelStaysClosed(ConfidenceLevel mailboxLevel)
{
Assert.That(MailboxRetrievalService.IsReadable(mailboxLevel, ConfidenceLevel.HIGH, ConfidenceLevel.HIGH), Is.False, "These levels would let almost every provider through.");
}
[Test]
public void AMailboxWithoutItsEmbeddingProviderStaysClosed()
{
Assert.That(MailboxRetrievalService.IsReadable(ConfidenceLevel.MEDIUM, ConfidenceLevel.HIGH, embeddingProviderConfidence: null), Is.False, "Nobody can tell whether a replacement would meet the level.");
}
[Test]
public void EveryMailIsListedOnceWithItsBestPassage()
{
var passages = MailboxRetrievalService.BestPassagePerMail([new("a", "a1"), new("b", "b1"), new("a", "a2"), new("c", "c1"), new("b", "b2")]);
Assert.That(passages, Is.EqualTo(new MailPassage[] { new("a", "a1"), new("b", "b1"), new("c", "c1") }));
}
[Test]
public void AChannelWhichFilledItsWindowMayHoldMoreMails()
{
Assert.Multiple(() =>
{
Assert.That(MailboxRetrievalService.MayHoldMoreMails(1, 10, 44, 44, 3), Is.True, "Its chunks may belong to fewer mails than the page needed.");
Assert.That(MailboxRetrievalService.MayHoldMoreMails(1, 10, 44, 43, 3), Is.False, "Both channels found less than they were asked for, so they found everything.");
Assert.That(MailboxRetrievalService.MayHoldMoreMails(9, 10, 364, 364, 364), Is.False, "Paging ends at the last page.");
});
}
[Test]
public async Task WithoutAQueryTheMailsArePagedNewestFirst()
{
var first = await MailboxRetrievalService.ListNewestFirstAsync(this.store.Client, MAILBOX, new MailFilter(), 1, 2, TOKEN);
var second = await MailboxRetrievalService.ListNewestFirstAsync(this.store.Client, MAILBOX, new MailFilter(), 2, 2, TOKEN);
Assert.Multiple(() =>
{
Assert.That(first.Hits.Select(hit => hit.Summary.MailId), Is.EqualTo(new[] { this.answer, this.question }));
Assert.That(first.HasMore, Is.True);
Assert.That(second.Hits.Select(hit => hit.Summary.MailId), Is.EqualTo(new[] { this.newsletter }), "Neither the mail gone from the server nor the one of another mailbox shows up.");
Assert.That(second.HasMore, Is.False);
Assert.That(first.Hits.Concat(second.Hits).Select(hit => hit.Passage), Is.All.Null, "Without a query, no passage matched anything.");
});
}
[Test]
public async Task TheConditionsNarrowTheListedMails()
{
var unread = await MailboxRetrievalService.ListNewestFirstAsync(this.store.Client, MAILBOX, new MailFilter { IsUnread = true }, 1, 10, TOKEN);
Assert.That(unread.Hits.Select(hit => hit.Summary.MailId), Is.EqualTo(new[] { this.answer }));
}
[Test]
public async Task AReplyLeadsToTheMailItAnswers()
{
var reading = await MailboxRetrievalService.ReadMailAsync(this.store.Client, [WORK, PRIVATE], this.answer, TOKEN);
Assert.That(reading, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(reading!.Mailbox.Id, Is.EqualTo(MAILBOX));
Assert.That(reading.Summary.Subject, Is.EqualTo("Re: Question"));
Assert.That(reading.Mail.MessageId, Is.EqualTo("answer@example.org"));
Assert.That(reading.InReplyToMailId, Is.EqualTo(this.question));
});
}
[Test]
public async Task AMailIsReadOnlyFromTheMailboxesGiven()
{
var fromAnotherMailbox = await MailboxRetrievalService.ReadMailAsync(this.store.Client, [WORK], this.elsewhere, TOKEN);
var fromItsOwnMailbox = await MailboxRetrievalService.ReadMailAsync(this.store.Client, [WORK, PRIVATE], this.elsewhere, TOKEN);
Assert.Multiple(() =>
{
Assert.That(fromAnotherMailbox, Is.Null, "The provider may not read the mailbox which holds it.");
Assert.That(fromItsOwnMailbox?.Mailbox.Id, Is.EqualTo(OTHER_MAILBOX));
Assert.That(fromItsOwnMailbox?.InReplyToMailId, Is.Null, "It answers no mail.");
});
}
[Test]
public async Task AMailGoneFromTheServerIsNotRead()
{
var goneFromTheServer = await MailboxRetrievalService.ReadMailAsync(this.store.Client, [WORK], this.deleted, TOKEN);
var neverIndexed = await MailboxRetrievalService.ReadMailAsync(this.store.Client, [WORK], "4f1c9e2a-7b3d-4e8f-9a6c-2d5b8e1f3a70", TOKEN);
Assert.Multiple(() =>
{
Assert.That(goneFromTheServer, Is.Null, "It lost its last place on the server.");
Assert.That(neverIndexed, Is.Null, "Nobody ever indexed it.");
});
}
[Test]
public async Task AMailboxNeverSyncedKnowsOnlyItsPeriod()
{
var now = new DateTimeOffset(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
var coverage = await MailboxRetrievalService.ReadCoverageAsync(this.store.Client, WORK, now, TOKEN);
Assert.Multiple(() =>
{
Assert.That(coverage.ReceivedSinceUtc, Is.EqualTo(now.AddMonths(-6)));
Assert.That(coverage.LastCompleteSyncUtc, Is.Null, "The first sync is still running.");
Assert.That(coverage.SignInRefusedAtUtc, Is.Null);
Assert.That(coverage.PendingRemovalCount, Is.Null);
Assert.That(coverage.Folders.Select(folder => folder.Path), Is.EqualTo(new[] { INBOX }));
});
}
[Test]
public async Task TheCoverageTellsWhatTheIndexMisses()
{
var completed = new DateTimeOffset(2026, 10, 1, 9, 16, 0, TimeSpan.Zero);
var refused = new DateTimeOffset(2026, 10, 2, 8, 0, 0, TimeSpan.Zero);
await this.store.Client.CompleteMailboxSyncAsync(OTHER_MAILBOX, completed, TOKEN);
await this.store.Client.HoldBackMailRemovalAsync(OTHER_MAILBOX, 250, TOKEN);
await this.store.Client.UpsertMailboxAuthFailureAsync(OTHER_MAILBOX, new MailboxAuthFailure(refused, "Invalid credentials."), TOKEN);
var coverage = await MailboxRetrievalService.ReadCoverageAsync(this.store.Client, PRIVATE, refused, TOKEN);
Assert.Multiple(() =>
{
Assert.That(coverage.ReceivedSinceUtc, Is.Null, "The whole mailbox is indexed.");
Assert.That(coverage.LastCompleteSyncUtc, Is.EqualTo(completed));
Assert.That(coverage.SignInRefusedAtUtc, Is.EqualTo(refused), "No new mail arrives until the user deals with it.");
Assert.That(coverage.PendingRemovalCount, Is.EqualTo(250));
});
}
private static MailLocationRecord At(long uid, bool isSeen) => new(INBOX, uid, new MailFlags(isSeen, false, false));
private static MailRecord Mail(DateTimeOffset received, MailLocationRecord location) => new(
string.Empty,
string.Empty,
string.Empty,
[],
received,
received,
MailImportance.NORMAL,
MailEncryptionKind.NONE,
"mail-hash",
received,
[new MailAddressRecord(MailAddressRole.FROM, "alice@example.org", "Alice")],
[],
[location]);
}