mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 16:58:00 +00:00
Added mailboxes as local data sources (#1022)
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-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 }}) (-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 / 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
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
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-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 }}) (-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 / 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
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
1 parent
93be539e50
commit
c4400c0ff6
343 files changed
+26051
-3122
No files matched your search
@@ -0,0 +1,50 @@
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the row the index keeps for a data source as a whole.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whether a data source has an index at all is read from this row alone. A data source can end up
|
||||
/// with no document indexed but with documents known to fail, e.g., a folder of scans without a
|
||||
/// text layer, and those failures hang on this row.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class IndexStoreDataSourceStateTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "6f1d6a4e-6a5e-4c62-9a4f-0f2d2c8b7a11";
|
||||
|
||||
[Test]
|
||||
public async Task ADataSourceWithOnlyFailedDocumentsKeepsItsRowUntilItIsDeleted()
|
||||
{
|
||||
var token = CancellationToken.None;
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
|
||||
await store.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "LOCAL_DIRECTORY", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", "source-hash", 3, token);
|
||||
await store.Client.UpsertPermanentFailureAsync(
|
||||
DATA_SOURCE_ID,
|
||||
new PermanentIndexingFailure("d06c4351-2e57-2941-aa4c-d7a814bfa418", "/tmp/test-data/scan.pdf", "fingerprint", FileExtractionErrorCode.NO_CONTENT, "No text could be read.", DateTimeOffset.UtcNow),
|
||||
token);
|
||||
|
||||
Assert.That(
|
||||
await store.Client.GetDataSourceStateAsync(DATA_SOURCE_ID, token),
|
||||
Is.Not.Null,
|
||||
"No document made it into the index, but the failures did. Without the row nothing would remember them, and every run would read the scans again.");
|
||||
|
||||
await store.Client.DeleteDataSourceAsync(DATA_SOURCE_ID, token);
|
||||
|
||||
var stateAfterDeletion = await store.Client.GetDataSourceStateAsync(DATA_SOURCE_ID, token);
|
||||
await using var context = store.CreateContext();
|
||||
var failuresAfterDeletion = await context.PermanentIndexingFailures.CountAsync(token);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stateAfterDeletion, Is.Null);
|
||||
Assert.That(failuresAfterDeletion, Is.Zero, "The failures go with the data source.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Mail;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the lines the information page shows about the local RAG index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mails are documents of the index just like files, so they sit in the same table. A line about
|
||||
/// files must not count them nonetheless.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class IndexStoreDisplayInfoTests
|
||||
{
|
||||
private const string FOLDER_ID = "0b6e1f7c-3d2a-4e5f-9a8b-1c2d3e4f5a6b";
|
||||
private const string MAILBOX_ID = "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d";
|
||||
private const string EMBEDDING_ID = "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01";
|
||||
|
||||
private static readonly CancellationToken TOKEN = CancellationToken.None;
|
||||
|
||||
[Test]
|
||||
public async Task FilesAndMailsAreCountedApart()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await store.Client.UpsertDataSourceAsync(FOLDER_ID, "LOCAL_DIRECTORY", EMBEDDING_ID, "signature", "source-hash", 3, TOKEN);
|
||||
await store.Client.UpsertDataSourceAsync(MAILBOX_ID, "MAILBOX", EMBEDDING_ID, "signature", "source-hash", 3, TOKEN);
|
||||
|
||||
await AddDocumentAsync(store, FOLDER_ID, "/tmp/test-data/minutes.md");
|
||||
await AddDocumentAsync(store, FOLDER_ID, "/tmp/test-data/budget.md");
|
||||
await AddDocumentAsync(store, MAILBOX_ID, MailKey('a'));
|
||||
await AddFailureAsync(store, FOLDER_ID, "/tmp/test-data/scan.pdf");
|
||||
await AddFailureAsync(store, MAILBOX_ID, MailKey('b'));
|
||||
|
||||
var lines = await ReadDisplayInfoAsync(store);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(lines["Indexed files"], Is.EqualTo("2"));
|
||||
Assert.That(lines["Permanently skipped files"], Is.EqualTo("1"));
|
||||
Assert.That(lines["Indexed mails"], Is.EqualTo("1"));
|
||||
Assert.That(lines["Permanently skipped mails"], Is.EqualTo("1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WithoutMailsTheLinesAboutMailsAreLeftOut()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await store.Client.UpsertDataSourceAsync(FOLDER_ID, "LOCAL_DIRECTORY", EMBEDDING_ID, "signature", "source-hash", 3, TOKEN);
|
||||
await AddDocumentAsync(store, FOLDER_ID, "/tmp/test-data/minutes.md");
|
||||
|
||||
var lines = await ReadDisplayInfoAsync(store);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(lines["Indexed files"], Is.EqualTo("1"));
|
||||
Assert.That(lines, Does.Not.ContainKey("Indexed mails"));
|
||||
Assert.That(lines, Does.Not.ContainKey("Permanently skipped mails"));
|
||||
});
|
||||
}
|
||||
|
||||
private static string MailKey(char filler) => MailContentKey.PREFIX + new string(filler, 64);
|
||||
|
||||
private static Task AddDocumentAsync(TemporaryIndexStore store, string dataSourceId, string key)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return store.Client.UpsertFileAsync(dataSourceId, new EmbeddingStateFile(IndexedDocumentIds.CreateParentId(dataSourceId, key), key, "name", "name", "md", "fingerprint", 64, now, now, now, 1), TOKEN);
|
||||
}
|
||||
|
||||
private static Task AddFailureAsync(TemporaryIndexStore store, string dataSourceId, string key) => store.Client.UpsertPermanentFailureAsync(
|
||||
dataSourceId,
|
||||
new PermanentIndexingFailure(IndexedDocumentIds.CreateParentId(dataSourceId, key), key, "fingerprint", FileExtractionErrorCode.NO_CONTENT, "No text could be read.", DateTimeOffset.UtcNow),
|
||||
TOKEN);
|
||||
|
||||
private static async Task<Dictionary<string, string>> ReadDisplayInfoAsync(TemporaryIndexStore store)
|
||||
{
|
||||
var lines = new Dictionary<string, string>();
|
||||
await foreach (var (label, value) in store.Client.GetDisplayInfo())
|
||||
lines[label] = value;
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the full-text search over the chunks of a data source of files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its results are cut into pages by asking for more of them each time, cf. RetrievalPaging. So
|
||||
/// chunks of the same score have to come in the same order with every limit, or a page would show
|
||||
/// a chunk again or skip one.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class IndexStoreFullTextSearchTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "6f1d6a4e-6a5e-4c62-9a4f-0f2d2c8b7a11";
|
||||
|
||||
private static readonly CancellationToken TOKEN = CancellationToken.None;
|
||||
|
||||
[Test]
|
||||
public async Task TheBestChunkComesFirstAndTiesKeepAFixedOrder()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await store.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "LOCAL_DIRECTORY", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
|
||||
|
||||
var best = await StoreFileAsync(store, "/tmp/test-data/best.md", "report report report", "nothing to see here");
|
||||
var tieOne = await StoreFileAsync(store, "/tmp/test-data/tie-one.md", "a report among other words");
|
||||
var tieTwo = await StoreFileAsync(store, "/tmp/test-data/tie-two.md", "a report among other words");
|
||||
var ties = new[] { tieOne, tieTwo }.Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
var all = await store.Client.SearchChunksAsync(DATA_SOURCE_ID, "report", 10, TOKEN);
|
||||
var firstTwo = await store.Client.SearchChunksAsync(DATA_SOURCE_ID, "report", 2, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(all.Select(result => (result.ParentFileId, result.ChunkIndex)), Is.EqualTo(new[] { (best, 0), (ties[0], 0), (ties[1], 0) }), "The chunk without the word is not found, and the tie falls by document.");
|
||||
Assert.That(firstTwo.Select(result => result.ParentFileId), Is.EqualTo(new[] { best, ties[0] }), "A smaller limit cuts the same order short.");
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<string> StoreFileAsync(TemporaryIndexStore store, string path, params string[] chunkTexts)
|
||||
{
|
||||
var parentFileId = IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, path);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await store.Client.UpsertFileAsync(DATA_SOURCE_ID, new EmbeddingStateFile(parentFileId, path, Path.GetFileName(path), Path.GetFileName(path), "md", path, 64, now, now, now, chunkTexts.Length), TOKEN);
|
||||
await store.Client.UpsertChunksAsync(
|
||||
DATA_SOURCE_ID,
|
||||
chunkTexts.Select((text, index) => new EmbeddingStateChunk(IndexedDocumentIds.CreateChunkId(DATA_SOURCE_ID, path, index), parentFileId, null, index, text, now)).ToList(),
|
||||
TOKEN);
|
||||
|
||||
return parentFileId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Mail;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the mail tools find, count and summarize mails in the index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The conditions come from the model, and the user relies on them: "unread mails from Bob" that
|
||||
/// also lists read ones, or a mail from another mailbox, is a wrong answer told with confidence.
|
||||
/// So each condition is checked on its own against a small mailbox, together with the two mails
|
||||
/// which must never show up: one which lies nowhere any more, and one of another mailbox.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class MailQueryStoreTests
|
||||
{
|
||||
private const string MAILBOX = "0c3f9b52-7d4e-4a1b-9e6f-2b8c5d7a1e40";
|
||||
private const string OTHER_MAILBOX = "5a1e9c37-2b4d-4e8f-a6c0-3d7b9e1f2a48";
|
||||
private const string INBOX = "INBOX";
|
||||
private const string ARCHIVE = "Archive/2026";
|
||||
|
||||
private const string REPORT = "report";
|
||||
private const string BUDGET = "budget";
|
||||
private const string NEWSLETTER = "newsletter";
|
||||
private const string DELETED = "deleted";
|
||||
private const string ELSEWHERE = "elsewhere";
|
||||
|
||||
private static readonly CancellationToken TOKEN = CancellationToken.None;
|
||||
|
||||
private readonly Dictionary<string, string> mailIds = new(StringComparer.Ordinal);
|
||||
|
||||
private TemporaryIndexStore store = null!;
|
||||
|
||||
private IndexStoreClient Client => this.store.Client;
|
||||
|
||||
[SetUp]
|
||||
public async Task CreateMailboxesAsync()
|
||||
{
|
||||
this.mailIds.Clear();
|
||||
this.store = await TemporaryIndexStore.CreateAsync();
|
||||
foreach (var dataSourceId in new[] { MAILBOX, OTHER_MAILBOX })
|
||||
{
|
||||
await this.Client.UpsertDataSourceAsync(dataSourceId, "MAILBOX", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
|
||||
await this.Client.UpsertMailFolderAsync(dataSourceId, new MailFolderRecord(INBOX, MailFolderSpecialUse.NONE, 1, null, null, null, null, null), TOKEN);
|
||||
}
|
||||
|
||||
await this.Client.UpsertMailFolderAsync(MAILBOX, new MailFolderRecord(ARCHIVE, MailFolderSpecialUse.ARCHIVE, 1, null, null, null, null, null), TOKEN);
|
||||
|
||||
await this.StoreMailAsync(MAILBOX, REPORT, "Quarterly report", "The quarterly report is ready for review.", Mail(
|
||||
new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero),
|
||||
[From("alice@example.org", "Alice Müller"), To("bob@example.org", "Bob")],
|
||||
[At(INBOX, 1, isSeen: true)]) with { MessageId = "report@example.org" });
|
||||
|
||||
await this.StoreMailAsync(MAILBOX, BUDGET, "Budget 2027", "Comments on the report and the budget.", Mail(
|
||||
new DateTimeOffset(2026, 9, 10, 9, 30, 0, TimeSpan.Zero),
|
||||
[From("bob@example.org", "Bob"), To("alice@example.org", "Alice Müller"), Cc("carol@example.org", "Carol")],
|
||||
[new MailLocationRecord(INBOX, 2, new MailFlags(false, true, false))]) with
|
||||
{
|
||||
MessageId = "budget@example.org",
|
||||
InReplyTo = "report@example.org",
|
||||
Importance = MailImportance.HIGH,
|
||||
Parts = [new MailPartRecord(MailPartKind.ATTACHMENT, "budget.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 52_000, "Budget", MailPartTextState.EXTRACTED)],
|
||||
});
|
||||
|
||||
await this.StoreMailAsync(MAILBOX, NEWSLETTER, "Newsletter August", "Content encrypted.", Mail(
|
||||
new DateTimeOffset(2026, 8, 15, 7, 0, 0, TimeSpan.Zero),
|
||||
[From("news@lists.example.org", "Example News"), To("alice@example.org", "Alice Müller")],
|
||||
[At(ARCHIVE, 5, isSeen: true)]) with { EncryptionKind = MailEncryptionKind.SMIME });
|
||||
|
||||
await this.StoreMailAsync(MAILBOX, DELETED, "Old report", "The report of a deleted mail.", Mail(
|
||||
new DateTimeOffset(2026, 9, 20, 7, 0, 0, TimeSpan.Zero),
|
||||
[From("alice@example.org", "Alice Müller")],
|
||||
[At(INBOX, 3)]) with { MessageId = "deleted@example.org" });
|
||||
|
||||
await this.Client.RemoveMailLocationsAsync(MAILBOX, INBOX, [3], TOKEN);
|
||||
|
||||
await this.StoreMailAsync(OTHER_MAILBOX, ELSEWHERE, "Quarterly report", "The report in another mailbox.", Mail(
|
||||
new DateTimeOffset(2026, 9, 25, 7, 0, 0, TimeSpan.Zero),
|
||||
[From("alice@example.org", "Alice Müller")],
|
||||
[At(INBOX, 1)]) with { MessageId = "report@example.org" });
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task DeleteMailboxesAsync() => await this.store.DisposeAsync();
|
||||
|
||||
private static IEnumerable<TestCaseData> Conditions()
|
||||
{
|
||||
yield return Case("no condition", new MailFilter(), BUDGET, REPORT, NEWSLETTER);
|
||||
yield return Case("from, by address", new MailFilter { From = "alice" }, REPORT);
|
||||
yield return Case("from, by name", new MailFilter { From = "Müller" }, REPORT);
|
||||
yield return Case("from, in other case", new MailFilter { From = "BOB@EXAMPLE.ORG" }, BUDGET);
|
||||
yield return Case("to, including Cc", new MailFilter { To = "carol" }, BUDGET);
|
||||
yield return Case("to, several mails", new MailFilter { To = "alice" }, BUDGET, NEWSLETTER);
|
||||
yield return Case("received since, inclusive", new MailFilter { ReceivedSinceUtc = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero) }, BUDGET, REPORT);
|
||||
yield return Case("received before, exclusive", new MailFilter { ReceivedBeforeUtc = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero) }, NEWSLETTER);
|
||||
yield return Case("received since, in another time zone", new MailFilter { ReceivedSinceUtc = new DateTimeOffset(2026, 9, 1, 10, 0, 1, TimeSpan.FromHours(2)) }, BUDGET);
|
||||
yield return Case("unread", new MailFilter { IsUnread = true }, BUDGET);
|
||||
yield return Case("read", new MailFilter { IsUnread = false }, REPORT, NEWSLETTER);
|
||||
yield return Case("flagged", new MailFilter { IsFlagged = true }, BUDGET);
|
||||
yield return Case("not flagged", new MailFilter { IsFlagged = false }, REPORT, NEWSLETTER);
|
||||
yield return Case("encrypted", new MailFilter { IsEncrypted = true }, NEWSLETTER);
|
||||
yield return Case("not encrypted", new MailFilter { IsEncrypted = false }, BUDGET, REPORT);
|
||||
yield return Case("high importance", new MailFilter { Importance = MailImportance.HIGH }, BUDGET);
|
||||
yield return Case("normal importance", new MailFilter { Importance = MailImportance.NORMAL }, REPORT, NEWSLETTER);
|
||||
yield return Case("with attachments", new MailFilter { HasAttachments = true }, BUDGET);
|
||||
yield return Case("without attachments", new MailFilter { HasAttachments = false }, REPORT, NEWSLETTER);
|
||||
yield return Case("in a folder", new MailFilter { FolderPaths = [ARCHIVE] }, NEWSLETTER);
|
||||
yield return Case("in no folder at all", new MailFilter { FolderPaths = [] });
|
||||
yield return Case("both conditions, none meets them", new MailFilter { From = "alice", IsUnread = true });
|
||||
yield return Case("both conditions, one meets them", new MailFilter { From = "bob", HasAttachments = true }, BUDGET);
|
||||
yield return Case("a percent sign is no wildcard", new MailFilter { From = "%" });
|
||||
yield return Case("an underscore is no wildcard", new MailFilter { To = "b_b" });
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Conditions))]
|
||||
public async Task EachConditionNarrowsTheMailsNewestFirst(MailFilter filter, string[] expectedMails)
|
||||
{
|
||||
var found = await this.Client.QueryMailsAsync(MAILBOX, filter, 0, 10, TOKEN);
|
||||
|
||||
Assert.That(found, Is.EqualTo(this.IdsOf(expectedMails)), "A mail which lies nowhere any more, or which belongs to another mailbox, must never show up.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PagesFollowEachOtherWithoutGapsOrRepeats()
|
||||
{
|
||||
var first = await this.Client.QueryMailsAsync(MAILBOX, new MailFilter(), 0, 2, TOKEN);
|
||||
var second = await this.Client.QueryMailsAsync(MAILBOX, new MailFilter(), 2, 2, TOKEN);
|
||||
var beyond = await this.Client.QueryMailsAsync(MAILBOX, new MailFilter(), 3, 2, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(first, Is.EqualTo(this.IdsOf(BUDGET, REPORT)));
|
||||
Assert.That(second, Is.EqualTo(this.IdsOf(NEWSLETTER)));
|
||||
Assert.That(beyond, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ACopyReadInAnyFolderMakesTheMailRead()
|
||||
{
|
||||
await this.Client.AddMailLocationAsync(MAILBOX, this.mailIds[BUDGET], At(ARCHIVE, 9, isSeen: true), TOKEN);
|
||||
|
||||
var unread = await this.Client.QueryMailsAsync(MAILBOX, new MailFilter { IsUnread = true }, 0, 10, TOKEN);
|
||||
var summary = (await this.Client.GetMailSummariesAsync(MAILBOX, [this.mailIds[BUDGET]], TOKEN)).Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unread, Is.Empty, "Whoever read the copy in the archive read the mail.");
|
||||
Assert.That(summary.Flags, Is.EqualTo(new MailFlags(true, true, false)));
|
||||
Assert.That(summary.FolderPaths, Is.EqualTo(new[] { ARCHIVE, INBOX }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheChunkIdsAreThoseOfTheMatchingMailsOnly()
|
||||
{
|
||||
var flagged = await this.Client.GetMailChunkIdsAsync(MAILBOX, new MailFilter { IsFlagged = true }, TOKEN);
|
||||
var all = await this.Client.GetMailChunkIdsAsync(MAILBOX, new MailFilter(), TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(flagged, Is.EqualTo(new[] { ChunkIdOf(MAILBOX, BUDGET) }));
|
||||
Assert.That(all, Is.EquivalentTo(new[] { ChunkIdOf(MAILBOX, REPORT), ChunkIdOf(MAILBOX, BUDGET), ChunkIdOf(MAILBOX, NEWSLETTER) }), "The chunks of the deleted mail are still in the vector store, but must not be searched.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheFullTextSearchStaysWithinTheMailboxAndTheConditions()
|
||||
{
|
||||
var everywhere = await this.Client.SearchMailChunksAsync(MAILBOX, "report", new MailFilter(), 10, TOKEN);
|
||||
var flagged = await this.Client.SearchMailChunksAsync(MAILBOX, "report", new MailFilter { IsFlagged = true }, 10, TOKEN);
|
||||
var bySubject = await this.Client.SearchMailChunksAsync(MAILBOX, "newsletter", new MailFilter(), 10, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(everywhere.Select(result => result.ParentFileId), Is.EquivalentTo(this.IdsOf(REPORT, BUDGET)));
|
||||
Assert.That(flagged.Select(result => result.ParentFileId), Is.EqualTo(this.IdsOf(BUDGET)));
|
||||
Assert.That(bySubject.Select(result => result.ParentFileId), Is.EqualTo(this.IdsOf(NEWSLETTER)), "The subject is searched along with the text.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CountsFollowTheConditions()
|
||||
{
|
||||
var total = await this.Client.CountMailsAsync(MAILBOX, new MailFilter(), MailCountGrouping.NONE, 20, TOKEN);
|
||||
var byFolder = await this.Client.CountMailsAsync(MAILBOX, new MailFilter(), MailCountGrouping.FOLDER, 20, TOKEN);
|
||||
var bySender = await this.Client.CountMailsAsync(MAILBOX, new MailFilter(), MailCountGrouping.SENDER, 2, TOKEN);
|
||||
var flaggedBySender = await this.Client.CountMailsAsync(MAILBOX, new MailFilter { IsFlagged = true }, MailCountGrouping.SENDER, 20, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(total.TotalCount, Is.EqualTo(3));
|
||||
Assert.That(total.Groups, Is.Empty);
|
||||
Assert.That(byFolder.TotalCount, Is.EqualTo(3));
|
||||
Assert.That(byFolder.Groups, Is.EqualTo(new MailCountGroup[] { new(INBOX, string.Empty, 2), new(ARCHIVE, string.Empty, 1) }), "Largest group first.");
|
||||
Assert.That(bySender.Groups, Is.EqualTo(new MailCountGroup[] { new("alice@example.org", "Alice Müller", 1), new("bob@example.org", "Bob", 1) }), "Groups of the same size by their key, and no more than asked for.");
|
||||
Assert.That(flaggedBySender.TotalCount, Is.EqualTo(1));
|
||||
Assert.That(flaggedBySender.Groups, Is.EqualTo(new MailCountGroup[] { new("bob@example.org", "Bob", 1) }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMailInTwoFoldersCountsOnceInTotalButInEachFolder()
|
||||
{
|
||||
await this.Client.AddMailLocationAsync(MAILBOX, this.mailIds[BUDGET], At(ARCHIVE, 9), TOKEN);
|
||||
|
||||
var byFolder = await this.Client.CountMailsAsync(MAILBOX, new MailFilter(), MailCountGrouping.FOLDER, 20, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(byFolder.TotalCount, Is.EqualTo(3));
|
||||
Assert.That(byFolder.Groups, Is.EqualTo(new MailCountGroup[] { new(ARCHIVE, string.Empty, 2), new(INBOX, string.Empty, 2) }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ASummaryShowsWhatAListOfMailsNeeds()
|
||||
{
|
||||
var requested = new[] { this.mailIds[BUDGET], "no-such-mail", this.mailIds[DELETED], this.mailIds[REPORT], this.mailIds[ELSEWHERE] };
|
||||
|
||||
var summaries = await this.Client.GetMailSummariesAsync(MAILBOX, requested, TOKEN);
|
||||
|
||||
Assert.That(summaries.Select(summary => summary.MailId), Is.EqualTo(this.IdsOf(BUDGET, REPORT)), "In the order asked for, without unknown mails, deleted ones, or those of another mailbox.");
|
||||
var budget = summaries[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(budget.Subject, Is.EqualTo("Budget 2027"));
|
||||
Assert.That(budget.ReceivedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 9, 10, 9, 30, 0, TimeSpan.Zero)));
|
||||
Assert.That(budget.MessageId, Is.EqualTo("budget@example.org"));
|
||||
Assert.That(budget.InReplyTo, Is.EqualTo("report@example.org"));
|
||||
Assert.That(budget.Addresses, Is.EqualTo(new[] { From("bob@example.org", "Bob"), To("alice@example.org", "Alice Müller"), Cc("carol@example.org", "Carol") }));
|
||||
Assert.That(budget.FolderPaths, Is.EqualTo(new[] { INBOX }));
|
||||
Assert.That(budget.Flags, Is.EqualTo(new MailFlags(false, true, false)));
|
||||
Assert.That(budget.Importance, Is.EqualTo(MailImportance.HIGH));
|
||||
Assert.That(budget.EncryptionKind, Is.EqualTo(MailEncryptionKind.NONE));
|
||||
Assert.That(budget.AttachmentNames, Is.EqualTo(new[] { "budget.xlsx" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AReplyFindsTheMailItAnswersInItsOwnMailbox()
|
||||
{
|
||||
var answered = await this.Client.FindMailByMessageIdAsync(MAILBOX, "report@example.org", TOKEN);
|
||||
var answeredElsewhere = await this.Client.FindMailByMessageIdAsync(OTHER_MAILBOX, "report@example.org", TOKEN);
|
||||
var deleted = await this.Client.FindMailByMessageIdAsync(MAILBOX, "deleted@example.org", TOKEN);
|
||||
var none = await this.Client.FindMailByMessageIdAsync(MAILBOX, string.Empty, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(answered, Is.EqualTo(this.mailIds[REPORT]));
|
||||
Assert.That(answeredElsewhere, Is.EqualTo(this.mailIds[ELSEWHERE]), "The same Message-ID in another mailbox is that mailbox's own copy.");
|
||||
Assert.That(deleted, Is.Null);
|
||||
Assert.That(none, Is.Null, "Mails without a Message-ID must not all answer each other.");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task StoreMailAsync(string dataSourceId, string name, string subject, string text, MailRecord mail) =>
|
||||
this.mailIds[name] = await this.store.StoreMailAsync(dataSourceId, name, subject, text, mail);
|
||||
|
||||
private string[] IdsOf(params string[] names) => names.Select(name => this.mailIds[name]).ToArray();
|
||||
|
||||
private static string ChunkIdOf(string dataSourceId, string name) => IndexedDocumentIds.CreateChunkId(dataSourceId, name, 0);
|
||||
|
||||
private static TestCaseData Case(string name, MailFilter filter, params string[] expectedMails) => new TestCaseData(filter, expectedMails).SetArgDisplayNames(name);
|
||||
|
||||
private static MailAddressRecord From(string address, string displayName) => new(MailAddressRole.FROM, address, displayName);
|
||||
|
||||
private static MailAddressRecord To(string address, string displayName) => new(MailAddressRole.TO, address, displayName);
|
||||
|
||||
private static MailAddressRecord Cc(string address, string displayName) => new(MailAddressRole.CC, address, displayName);
|
||||
|
||||
private static MailLocationRecord At(string folderPath, long uid, bool isSeen = false) => new(folderPath, uid, new MailFlags(isSeen, false, false));
|
||||
|
||||
private static MailRecord Mail(DateTimeOffset received, IReadOnlyList<MailAddressRecord> addresses, IReadOnlyList<MailLocationRecord> locations) => new(
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
[],
|
||||
received,
|
||||
received,
|
||||
MailImportance.NORMAL,
|
||||
MailEncryptionKind.NONE,
|
||||
"mail-hash",
|
||||
received,
|
||||
addresses,
|
||||
[],
|
||||
locations);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the tables the index keeps for mailboxes, above all what goes with what.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a delete takes with it is decided by the foreign keys, so a wrong one only shows once it
|
||||
/// did its damage: a rebuild which tries a refused password again and locks the account, or a
|
||||
/// removed folder which takes the mails along that were only moved out of it. These tests run
|
||||
/// against a real SQLite file, migrated the way the app migrates its own, because only that
|
||||
/// applies the cascades.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class MailboxIndexSchemaTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "0c3f9b52-7d4e-4a1b-9e6f-2b8c5d7a1e40";
|
||||
private const string MAIL_KEY = "mail:5d41402abc4b2a76b9719d911017c592";
|
||||
|
||||
private static readonly string MAIL_ID = IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, MAIL_KEY);
|
||||
|
||||
[Test]
|
||||
public void TheModelSnapshotMatchesTheModel()
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
using var context = new IndexStoreDbContext(IndexStoreDbContext.CreateOptions(Path.Combine(Path.GetTempPath(), "ai-studio-never-opened.sqlite3")));
|
||||
|
||||
Assert.That(context.Database.HasPendingModelChanges(), Is.False, "EF Core refuses to migrate while the snapshot and the model disagree, and the app would start without its index.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RebuildingAMailboxKeepsARefusedSignIn()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await AddIndexedMailAsync(store);
|
||||
|
||||
await store.Client.DeleteDataSourceAsync(DATA_SOURCE_ID, CancellationToken.None);
|
||||
|
||||
Assert.That(
|
||||
await CountRowsAsync(store),
|
||||
Is.EqualTo(new RowCounts(Messages: 0, Addresses: 0, Parts: 0, Folders: 0, Locations: 0, SyncStates: 0, AuthStates: 1)),
|
||||
"Everything of the index goes, but the refused sign-in stays. Otherwise the rebuild would try the refused password again.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task IndexingAMailAgainTakesItsMetadataAlong()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await AddIndexedMailAsync(store);
|
||||
|
||||
// This is what indexing a document starts with:
|
||||
await store.Client.DeleteFileAsync(DATA_SOURCE_ID, MAIL_KEY, CancellationToken.None);
|
||||
|
||||
Assert.That(
|
||||
await CountRowsAsync(store),
|
||||
Is.EqualTo(new RowCounts(Messages: 0, Addresses: 0, Parts: 0, Folders: 1, Locations: 0, SyncStates: 1, AuthStates: 1)),
|
||||
"The metadata of the mail goes with its document, which is why it is written after the last chunk. The folder and the sync state belong to the mailbox and stay.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARemovedFolderTakesItsLocationsButLeavesTheMail()
|
||||
{
|
||||
await using var store = await TemporaryIndexStore.CreateAsync();
|
||||
await AddIndexedMailAsync(store);
|
||||
|
||||
await using (var context = store.CreateContext())
|
||||
await context.MailFolders.ExecuteDeleteAsync();
|
||||
|
||||
Assert.That(
|
||||
await CountRowsAsync(store),
|
||||
Is.EqualTo(new RowCounts(Messages: 1, Addresses: 1, Parts: 1, Folders: 0, Locations: 0, SyncStates: 1, AuthStates: 1)),
|
||||
"The mail may have been moved to another folder, so whether it is gone is for the sync to decide, not for the folder.");
|
||||
}
|
||||
|
||||
private static async Task AddIndexedMailAsync(TemporaryIndexStore store)
|
||||
{
|
||||
var token = CancellationToken.None;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await store.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "MAILBOX", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", "source-hash", 3, token);
|
||||
await store.Client.UpsertFileAsync(DATA_SOURCE_ID, new EmbeddingStateFile(MAIL_ID, MAIL_KEY, "Quarterly report", MAIL_KEY, "mail", "fingerprint", 2048, now, now, now, 1), token);
|
||||
await store.Client.UpsertChunksAsync(DATA_SOURCE_ID, [new EmbeddingStateChunk(IndexedDocumentIds.CreateChunkId(DATA_SOURCE_ID, "fingerprint", 0), MAIL_ID, null, 0, "From: Alice. Subject: Quarterly report.", now)], token);
|
||||
|
||||
await using (var context = store.CreateContext())
|
||||
{
|
||||
var folder = new MailFolderEntity { DataSourceId = DATA_SOURCE_ID, Path = "INBOX", SpecialUse = "NONE", UidValidity = 1 };
|
||||
context.MailFolders.Add(folder);
|
||||
context.MailMessages.Add(new MailMessageEntity
|
||||
{
|
||||
ParentFileId = MAIL_ID,
|
||||
DataSourceId = DATA_SOURCE_ID,
|
||||
MessageId = "report@example.org",
|
||||
ReceivedAtUtc = now,
|
||||
Importance = "NORMAL",
|
||||
EncryptionKind = "NONE",
|
||||
MailHash = "mail-hash",
|
||||
FirstSeenUtc = now,
|
||||
Addresses = [new MailAddressEntity { Role = "FROM", Address = "alice@example.org", DisplayName = "Alice" }],
|
||||
Parts = [new MailPartEntity { Kind = "BODY", ContentType = "text/html", PartSize = 2048, Text = "The report.", TextState = "EXTRACTED" }],
|
||||
Locations = [new MailLocationEntity { Folder = folder, Uid = 42 }],
|
||||
});
|
||||
|
||||
context.MailboxSyncStates.Add(new MailboxSyncStateEntity { DataSourceId = DATA_SOURCE_ID });
|
||||
context.MailboxAuthStates.Add(new MailboxAuthStateEntity { DataSourceId = DATA_SOURCE_ID, FailedAtUtc = now, FailureMessage = "Invalid credentials." });
|
||||
await context.SaveChangesAsync(token);
|
||||
}
|
||||
|
||||
Assert.That(
|
||||
await CountRowsAsync(store),
|
||||
Is.EqualTo(new RowCounts(Messages: 1, Addresses: 1, Parts: 1, Folders: 1, Locations: 1, SyncStates: 1, AuthStates: 1)),
|
||||
"Every table has to hold a row first, or a test could not tell what a delete takes along.");
|
||||
}
|
||||
|
||||
private static async Task<RowCounts> CountRowsAsync(TemporaryIndexStore store)
|
||||
{
|
||||
await using var context = store.CreateContext();
|
||||
return new RowCounts(
|
||||
await context.MailMessages.CountAsync(),
|
||||
await context.MailAddresses.CountAsync(),
|
||||
await context.MailParts.CountAsync(),
|
||||
await context.MailFolders.CountAsync(),
|
||||
await context.MailLocations.CountAsync(),
|
||||
await context.MailboxSyncStates.CountAsync(),
|
||||
await context.MailboxAuthStates.CountAsync());
|
||||
}
|
||||
|
||||
// Compared as a whole, and printed as a whole when a test fails, so the properties are read through Equals and ToString only:
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
private sealed record RowCounts(int Messages, int Addresses, int Parts, int Folders, int Locations, int SyncStates, int AuthStates);
|
||||
// ReSharper restore NotAccessedPositionalProperty.Local
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Mail;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the index keeps track of mails, their folders and the places they lie in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Embedding a mail is the expensive part, with a cloud provider a paid one. So what matters most
|
||||
/// here is that nothing forces a mail to be embedded again which is only somewhere else now: a
|
||||
/// mail moved to another folder, a folder whose UIDs the server renumbered, a mail which shows up
|
||||
/// in a later run than the one it disappeared in.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class MailboxIndexStoreTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "0c3f9b52-7d4e-4a1b-9e6f-2b8c5d7a1e40";
|
||||
private const string KEY_A = "mail:5d41402abc4b2a76b9719d911017c592";
|
||||
private const string KEY_B = "mail:7d793037a0760186574b0282f2f435e7";
|
||||
private const string INBOX = "INBOX";
|
||||
private const string ARCHIVE = "Archive/2026";
|
||||
|
||||
private static readonly DateTimeOffset FIRST_SEEN = new(2026, 9, 1, 8, 0, 0, TimeSpan.Zero);
|
||||
private static readonly CancellationToken TOKEN = CancellationToken.None;
|
||||
|
||||
private TemporaryIndexStore store = null!;
|
||||
|
||||
private IndexStoreClient Client => this.store.Client;
|
||||
|
||||
[SetUp]
|
||||
public async Task CreateMailboxAsync()
|
||||
{
|
||||
this.store = await TemporaryIndexStore.CreateAsync();
|
||||
await this.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "MAILBOX", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
|
||||
await this.Client.UpsertMailFolderAsync(DATA_SOURCE_ID, Folder(INBOX, 1), TOKEN);
|
||||
await this.Client.UpsertMailFolderAsync(DATA_SOURCE_ID, Folder(ARCHIVE, 1), TOKEN);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task DeleteMailboxAsync() => await this.store.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task AMailComesBackAsItWasStored()
|
||||
{
|
||||
var mail = Mail(await this.StoreDocumentAsync(KEY_A), At(INBOX, 7, isSeen: true), At(ARCHIVE, 3));
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, mail, TOKEN);
|
||||
|
||||
var stored = await this.Client.GetMailAsync(DATA_SOURCE_ID, mail.MailId, TOKEN);
|
||||
|
||||
Assert.That(stored, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stored! with { ReferenceMessageIds = mail.ReferenceMessageIds, Addresses = mail.Addresses, Parts = mail.Parts, Locations = mail.Locations }, Is.EqualTo(mail), "The dates come back in UTC, but have to name the same moments.");
|
||||
Assert.That(stored.ReferenceMessageIds, Is.EqualTo(mail.ReferenceMessageIds));
|
||||
Assert.That(stored.Addresses, Is.EqualTo(mail.Addresses));
|
||||
Assert.That(stored.Parts, Is.EqualTo(mail.Parts));
|
||||
Assert.That(stored.Locations, Is.EquivalentTo(mail.Locations));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFolderComesBackAsItWasStoredAndKeepsItsMails()
|
||||
{
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_A), At(INBOX, 7)), TOKEN);
|
||||
var synced = new MailFolderRecord(INBOX, MailFolderSpecialUse.NONE, 1, 812, 4711, 811, 12, FIRST_SEEN.AddHours(1));
|
||||
|
||||
await this.Client.UpsertMailFolderAsync(DATA_SOURCE_ID, synced, TOKEN);
|
||||
|
||||
var folders = await this.Client.GetMailFoldersAsync(DATA_SOURCE_ID, TOKEN);
|
||||
var inboxLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, INBOX, TOKEN);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(folders, Is.EqualTo(new[] { Folder(ARCHIVE, 1), synced }), "Ordered by path.");
|
||||
Assert.That(inboxLocations.Keys, Is.EquivalentTo(new long[] { 7 }), "The UIDVALIDITY did not change, so the UIDs stay valid.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StoringAMailAgainReplacesItsDetailsButNotWhenItWasFirstSeen()
|
||||
{
|
||||
var mailId = await this.StoreDocumentAsync(KEY_A);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId, At(INBOX, 7)), TOKEN);
|
||||
var again = Mail(mailId, At(ARCHIVE, 3)) with
|
||||
{
|
||||
FirstSeenUtc = FIRST_SEEN.AddDays(10),
|
||||
Addresses = [new MailAddressRecord(MailAddressRole.FROM, "alice@example.org", "Alice")],
|
||||
Parts = [new MailPartRecord(MailPartKind.BODY, string.Empty, "text/plain", 12, "The report.", MailPartTextState.EXTRACTED)],
|
||||
};
|
||||
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, again, TOKEN);
|
||||
|
||||
var stored = await this.Client.GetMailAsync(DATA_SOURCE_ID, mailId, TOKEN);
|
||||
var inboxLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, INBOX, TOKEN);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stored!.FirstSeenUtc, Is.EqualTo(FIRST_SEEN), "The mail was found before, and when it was first seen stays the earlier moment.");
|
||||
Assert.That(stored.Addresses, Is.EqualTo(again.Addresses));
|
||||
Assert.That(stored.Parts, Is.EqualTo(again.Parts));
|
||||
Assert.That(stored.Locations, Is.EqualTo(again.Locations));
|
||||
Assert.That(inboxLocations, Is.Empty, "The places are replaced as well, not added to.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMovedMailIsLinkedAnewInsteadOfBeingEmbeddedAgain()
|
||||
{
|
||||
var mailId = await this.StoreDocumentAsync(KEY_A);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId, At(INBOX, 7)), TOKEN);
|
||||
|
||||
await this.Client.RemoveMailLocationsAsync(DATA_SOURCE_ID, INBOX, [7], TOKEN);
|
||||
var orphanedAfterRemoval = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, DateTimeOffset.UtcNow.AddMinutes(1), TOKEN);
|
||||
|
||||
var linked = await this.Client.AddMailLocationAsync(DATA_SOURCE_ID, mailId, At(ARCHIVE, 3, isSeen: true), TOKEN);
|
||||
var orphanedAfterLinking = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, DateTimeOffset.UtcNow.AddMinutes(1), TOKEN);
|
||||
var archiveLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, ARCHIVE, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(orphanedAfterRemoval, Is.EqualTo(new[] { KEY_A }), "Without any place left, the mail is an orphan.");
|
||||
Assert.That(linked, Is.True);
|
||||
Assert.That(orphanedAfterLinking, Is.Empty, "Found in another folder, the mail is no orphan any more and keeps its embedding.");
|
||||
Assert.That(archiveLocations, Is.EqualTo(new Dictionary<long, MailFlags> { [3] = new(true, false, false) }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMailTheIndexDoesNotHoldCannotBeLinked()
|
||||
{
|
||||
var linked = await this.Client.AddMailLocationAsync(DATA_SOURCE_ID, IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, KEY_A), At(INBOX, 7), TOKEN);
|
||||
|
||||
Assert.That(linked, Is.False, "The caller has to index such a mail instead.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnOrphanIsOnlyDueInTheRunAfterTheOneWhichOrphanedIt()
|
||||
{
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_A), At(INBOX, 7)), TOKEN);
|
||||
|
||||
var runStart = DateTimeOffset.UtcNow;
|
||||
await this.Client.RemoveMailLocationsAsync(DATA_SOURCE_ID, INBOX, [7], TOKEN);
|
||||
var nextRunStart = DateTimeOffset.UtcNow;
|
||||
|
||||
var dueThisRun = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, runStart, TOKEN);
|
||||
var dueNextRun = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, nextRunStart, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(dueThisRun, Is.Empty, "The run which lost the mail may still find it in a folder it gets to later.");
|
||||
Assert.That(dueNextRun, Is.EqualTo(new[] { KEY_A }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANewUidValidityVoidsTheUidsOfItsFolderButKeepsTheMails()
|
||||
{
|
||||
var onlyInInbox = await this.StoreDocumentAsync(KEY_A);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(onlyInInbox, At(INBOX, 7)), TOKEN);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_B), At(INBOX, 8), At(ARCHIVE, 3)), TOKEN);
|
||||
|
||||
await this.Client.UpsertMailFolderAsync(DATA_SOURCE_ID, Folder(INBOX, 2), TOKEN);
|
||||
|
||||
var inboxLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, INBOX, TOKEN);
|
||||
var archiveLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, ARCHIVE, TOKEN);
|
||||
var orphaned = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, DateTimeOffset.UtcNow.AddMinutes(1), TOKEN);
|
||||
var orphan = await this.Client.GetMailAsync(DATA_SOURCE_ID, onlyInInbox, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(inboxLocations, Is.Empty, "Under a new UIDVALIDITY, the stored UIDs name nothing any more.");
|
||||
Assert.That(archiveLocations.Keys, Is.EquivalentTo(new long[] { 3 }), "Other folders are not affected.");
|
||||
Assert.That(orphaned, Is.EqualTo(new[] { KEY_A }), "Only the mail which lies nowhere else is an orphan.");
|
||||
Assert.That(orphan, Is.Not.Null, "Orphaned, not deleted: found again under its new UID, it is only linked anew.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARemovedFolderOrphansOnlyTheMailsWhichLieNowhereElse()
|
||||
{
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_A), At(INBOX, 7)), TOKEN);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_B), At(INBOX, 8), At(ARCHIVE, 3)), TOKEN);
|
||||
|
||||
await this.Client.DeleteMailFolderAsync(DATA_SOURCE_ID, INBOX, TOKEN);
|
||||
|
||||
var folders = await this.Client.GetMailFoldersAsync(DATA_SOURCE_ID, TOKEN);
|
||||
var orphaned = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, DateTimeOffset.UtcNow.AddMinutes(1), TOKEN);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(folders.Select(folder => folder.Path), Is.EqualTo(new[] { ARCHIVE }));
|
||||
Assert.That(orphaned, Is.EqualTo(new[] { KEY_A }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FlagsChangeOnlyWhereTheyWereSet()
|
||||
{
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(await this.StoreDocumentAsync(KEY_A), At(INBOX, 7), At(ARCHIVE, 3)), TOKEN);
|
||||
|
||||
await this.Client.UpdateMailFlagsAsync(DATA_SOURCE_ID, INBOX, new Dictionary<long, MailFlags> { [7] = new(true, true, false), [99] = new(true, false, false) }, TOKEN);
|
||||
|
||||
var inboxLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, INBOX, TOKEN);
|
||||
var archiveLocations = await this.Client.GetMailLocationsAsync(DATA_SOURCE_ID, ARCHIVE, TOKEN);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(inboxLocations, Is.EqualTo(new Dictionary<long, MailFlags> { [7] = new(true, true, false) }), "A UID the index does not hold is ignored, not added.");
|
||||
Assert.That(archiveLocations, Is.EqualTo(new Dictionary<long, MailFlags> { [3] = new(false, false, false) }), "IMAP keeps flags per folder, so the copy in the archive keeps its own.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AUidNamesOneMailOnly()
|
||||
{
|
||||
var first = await this.StoreDocumentAsync(KEY_A);
|
||||
var second = await this.StoreDocumentAsync(KEY_B);
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(first, At(INBOX, 7)), TOKEN);
|
||||
|
||||
await this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(second, At(INBOX, 7)), TOKEN);
|
||||
|
||||
var firstStored = await this.Client.GetMailAsync(DATA_SOURCE_ID, first, TOKEN);
|
||||
var secondStored = await this.Client.GetMailAsync(DATA_SOURCE_ID, second, TOKEN);
|
||||
var orphaned = await this.Client.GetOrphanedMailsAsync(DATA_SOURCE_ID, DateTimeOffset.UtcNow.AddMinutes(1), TOKEN);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(firstStored!.Locations, Is.Empty, "The server says another mail lies there now, so the stored place was stale.");
|
||||
Assert.That(secondStored!.Locations, Is.EqualTo(new[] { At(INBOX, 7) }));
|
||||
Assert.That(orphaned, Is.EqualTo(new[] { KEY_A }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMailIsOnlyStoredWithAPlaceInAStoredFolder()
|
||||
{
|
||||
var mailId = await this.StoreDocumentAsync(KEY_A);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.ThrowsAsync<ArgumentException>(() => this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId), TOKEN), "A mail which lies nowhere would be an orphan from the start.");
|
||||
Assert.ThrowsAsync<ArgumentException>(() => this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId, At(INBOX, 7), At(INBOX, 7)), TOKEN));
|
||||
Assert.ThrowsAsync<InvalidOperationException>(() => this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId, At("Unknown", 1)), TOKEN));
|
||||
Assert.ThrowsAsync<ArgumentException>(() => this.Client.UpsertMailAsync(DATA_SOURCE_ID, Mail(mailId, At(INBOX, 7)) with { ReferenceMessageIds = ["one two@example.org"] }, TOKEN), "Stored separated by spaces, this one would come back as two.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the document a mail rests on, as indexing does before it stores the mail.
|
||||
/// </summary>
|
||||
private async Task<string> StoreDocumentAsync(string mailKey)
|
||||
{
|
||||
var mailId = IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, mailKey);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await this.Client.UpsertFileAsync(DATA_SOURCE_ID, new EmbeddingStateFile(mailId, mailKey, "Quarterly report", mailKey, "mail", "fingerprint", 2048, now, now, now, 1), TOKEN);
|
||||
return mailId;
|
||||
}
|
||||
|
||||
private static MailFolderRecord Folder(string path, long uidValidity) => new(path, MailFolderSpecialUse.NONE, uidValidity, null, null, null, null, null);
|
||||
|
||||
private static MailLocationRecord At(string folderPath, long uid, bool isSeen = false) => new(folderPath, uid, new MailFlags(isSeen, false, false));
|
||||
|
||||
private static MailRecord Mail(string mailId, params MailLocationRecord[] locations) => new(
|
||||
mailId,
|
||||
"report@example.org",
|
||||
"draft@example.org",
|
||||
["kickoff@example.org", "draft@example.org"],
|
||||
new DateTimeOffset(2026, 9, 30, 14, 5, 0, TimeSpan.FromHours(2)),
|
||||
new DateTimeOffset(2026, 9, 30, 12, 5, 3, TimeSpan.Zero),
|
||||
MailImportance.HIGH,
|
||||
MailEncryptionKind.NONE,
|
||||
"mail-hash",
|
||||
FIRST_SEEN,
|
||||
[
|
||||
new MailAddressRecord(MailAddressRole.FROM, "alice@example.org", "Alice"),
|
||||
new MailAddressRecord(MailAddressRole.TO, "bob@example.org", "Bob"),
|
||||
new MailAddressRecord(MailAddressRole.TO, "carol@example.org", string.Empty),
|
||||
new MailAddressRecord(MailAddressRole.CC, "dave@example.org", "Dave"),
|
||||
],
|
||||
[
|
||||
new MailPartRecord(MailPartKind.HEADERS, string.Empty, "text/rfc822-headers", 812, "From: Alice <alice@example.org>", MailPartTextState.EXTRACTED),
|
||||
new MailPartRecord(MailPartKind.BODY, string.Empty, "text/html", 2048, "The report.", MailPartTextState.EXTRACTED),
|
||||
new MailPartRecord(MailPartKind.ATTACHMENT, "report.pdf", "application/pdf", 9_000_000, null, MailPartTextState.TOO_LARGE),
|
||||
],
|
||||
locations);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what the index keeps about a mailbox as a whole: its sync and a refused sign-in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both protect the user from something which cannot be taken back. A removal from the index the
|
||||
/// user did not agree to costs them a new embedding of every mail which comes back, and a sign-in
|
||||
/// tried again with a refused password may lock their directory account.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class MailboxStateStoreTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "0c3f9b52-7d4e-4a1b-9e6f-2b8c5d7a1e40";
|
||||
|
||||
private static readonly CancellationToken TOKEN = CancellationToken.None;
|
||||
|
||||
private TemporaryIndexStore store = null!;
|
||||
|
||||
private IndexStoreClient Client => this.store.Client;
|
||||
|
||||
[SetUp]
|
||||
public async Task CreateMailboxAsync()
|
||||
{
|
||||
this.store = await TemporaryIndexStore.CreateAsync();
|
||||
await this.Client.UpsertDataSourceAsync(DATA_SOURCE_ID, "MAILBOX", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task DeleteMailboxAsync() => await this.store.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task AMailboxWhichWasNeverSyncedKnowsNothingYet()
|
||||
{
|
||||
Assert.That(await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN), Is.EqualTo(new MailboxSyncState(null, null, null)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ACompleteSyncEndsAHeldBackRemoval()
|
||||
{
|
||||
var completed = new DateTimeOffset(2026, 10, 1, 9, 16, 0, TimeSpan.Zero);
|
||||
await this.Client.HoldBackMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
await this.Client.ApprovePendingMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
|
||||
await this.Client.CompleteMailboxSyncAsync(DATA_SOURCE_ID, completed, TOKEN);
|
||||
|
||||
Assert.That(
|
||||
await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN),
|
||||
Is.EqualTo(new MailboxSyncState(completed, null, null)),
|
||||
"The approved removal took place in that sync, so the next large one has to be asked for again.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnApprovalCoversOnlyTheCountTheUserWasShown()
|
||||
{
|
||||
await this.Client.HoldBackMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
|
||||
var approvedAnotherCount = await this.Client.ApprovePendingMailRemovalAsync(DATA_SOURCE_ID, 400, TOKEN);
|
||||
var stateAfterAnotherCount = await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
var approvedTheCount = await this.Client.ApprovePendingMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
await this.Client.HoldBackMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
var stateAfterTheSameCountAgain = await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
await this.Client.HoldBackMailRemovalAsync(DATA_SOURCE_ID, 800, TOKEN);
|
||||
var stateAfterMoreMails = await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(approvedAnotherCount, Is.False, "A sync changed the count while the user looked at the old one.");
|
||||
Assert.That(stateAfterAnotherCount.PendingRemovalApprovedUtc, Is.Null);
|
||||
Assert.That(approvedTheCount, Is.True);
|
||||
Assert.That(stateAfterTheSameCountAgain.PendingRemovalApprovedUtc, Is.Not.Null, "The next sync finds the same count and may go ahead.");
|
||||
Assert.That(stateAfterMoreMails, Is.EqualTo(new MailboxSyncState(null, 800, null)), "The user agreed to 500 mails, not to whatever comes.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NothingCanBeApprovedWhileNothingIsHeldBack()
|
||||
{
|
||||
var approved = await this.Client.ApprovePendingMailRemovalAsync(DATA_SOURCE_ID, 500, TOKEN);
|
||||
var state = await this.Client.GetMailboxSyncStateAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(approved, Is.False);
|
||||
Assert.That(state, Is.EqualTo(new MailboxSyncState(null, null, null)));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARemovalOfNoMailIsNothingToHoldBack()
|
||||
{
|
||||
Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => this.Client.HoldBackMailRemovalAsync(DATA_SOURCE_ID, 0, TOKEN));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARefusedSignInStaysUntilItIsCleared()
|
||||
{
|
||||
var first = new MailboxAuthFailure(new DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero), "AUTHENTICATIONFAILED Invalid credentials");
|
||||
var second = new MailboxAuthFailure(new DateTimeOffset(2026, 10, 2, 9, 0, 0, TimeSpan.Zero), string.Empty);
|
||||
|
||||
await this.Client.UpsertMailboxAuthFailureAsync(DATA_SOURCE_ID, first, TOKEN);
|
||||
var afterFirst = await this.Client.GetMailboxAuthFailureAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
await this.Client.UpsertMailboxAuthFailureAsync(DATA_SOURCE_ID, second, TOKEN);
|
||||
var afterSecond = await this.Client.GetMailboxAuthFailureAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
await this.Client.ClearMailboxAuthFailureAsync(DATA_SOURCE_ID, TOKEN);
|
||||
var afterClearing = await this.Client.GetMailboxAuthFailureAsync(DATA_SOURCE_ID, TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(afterFirst, Is.EqualTo(first));
|
||||
Assert.That(afterSecond, Is.EqualTo(second), "The one try the user asked for failed again.");
|
||||
Assert.That(afterClearing, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ASignInCanBeRefusedBeforeAnythingWasIndexed()
|
||||
{
|
||||
const string NEVER_INDEXED = "5a1e9c37-2b4d-4e8f-a6c0-3d7b9e1f2a48";
|
||||
var failure = new MailboxAuthFailure(DateTimeOffset.UtcNow, "AUTHENTICATIONFAILED Invalid credentials");
|
||||
|
||||
await this.Client.UpsertMailboxAuthFailureAsync(NEVER_INDEXED, failure, TOKEN);
|
||||
|
||||
Assert.That(await this.Client.GetMailboxAuthFailureAsync(NEVER_INDEXED, TOKEN), Is.EqualTo(failure), "The very first sign-in of a mailbox can fail, long before the index holds anything of it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EveryMailboxTheIndexKeepsSomethingOfIsListedOnce()
|
||||
{
|
||||
const string NEVER_INDEXED = "5a1e9c37-2b4d-4e8f-a6c0-3d7b9e1f2a48";
|
||||
const string FOLDER = "9d2b4f6a-1c3e-4a5b-8d7f-0e2c4a6b8d1f";
|
||||
var failure = new MailboxAuthFailure(DateTimeOffset.UtcNow, "AUTHENTICATIONFAILED Invalid credentials");
|
||||
|
||||
await this.Client.UpsertDataSourceAsync(FOLDER, "LOCAL_DIRECTORY", "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "signature", string.Empty, 3, TOKEN);
|
||||
await this.Client.UpsertMailboxAuthFailureAsync(DATA_SOURCE_ID, failure, TOKEN);
|
||||
await this.Client.UpsertMailboxAuthFailureAsync(NEVER_INDEXED, failure, TOKEN);
|
||||
|
||||
Assert.That(
|
||||
await this.Client.GetStoredMailboxIdsAsync(TOKEN),
|
||||
Is.EquivalentTo(new[] { DATA_SOURCE_ID, NEVER_INDEXED }),
|
||||
"A refused sign-in outlives the index, so it is left over just as well. A folder is no mailbox, whatever happens to it.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using AIStudio.Tools.Databases.IndexStore;
|
||||
using AIStudio.Tools.Services.Indexing;
|
||||
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace AIStudio.Tests.Tools.Databases;
|
||||
|
||||
/// <summary>
|
||||
/// An index store in a directory of its own, migrated the way the app migrates its own, and deleted
|
||||
/// again afterward.
|
||||
/// </summary>
|
||||
/// <param name="directory">The directory the database lives in.</param>
|
||||
/// <param name="databasePath">The path of the database file.</param>
|
||||
internal sealed class TemporaryIndexStore(string directory, string databasePath) : IAsyncDisposable
|
||||
{
|
||||
public SqliteIndexStoreClientImplementation Client { get; } = new("SQLite", databasePath, directory, string.Empty);
|
||||
|
||||
public static async Task<TemporaryIndexStore> CreateAsync()
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"ai-studio-index-store-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var databasePath = Path.Combine(directory, "rag-index.sqlite3");
|
||||
await using (var context = new IndexStoreDbContext(IndexStoreDbContext.CreateOptions(databasePath)))
|
||||
await IndexStoreSchemaMigrator.MigrateAsync(context, CancellationToken.None);
|
||||
|
||||
return new TemporaryIndexStore(directory, databasePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the database directly, to set up or check what the client does not cover.
|
||||
/// </summary>
|
||||
public IndexStoreDbContext CreateContext() => new(IndexStoreDbContext.CreateOptions(databasePath));
|
||||
|
||||
/// <summary>
|
||||
/// Stores a mail the way the sync does: first its document with a single chunk, then the mail itself.
|
||||
/// </summary>
|
||||
/// <param name="dataSourceId">The mailbox, which has to be stored together with the folders of the mail.</param>
|
||||
/// <param name="name">A name for the mail, unique within the mailbox. It makes up the key of the document and its fingerprint.</param>
|
||||
/// <param name="subject">The subject, which the document keeps as its file name.</param>
|
||||
/// <param name="text">The text of the single chunk.</param>
|
||||
/// <param name="mail">The mail. Its id is replaced by the id of its document.</param>
|
||||
/// <returns>The id of the mail.</returns>
|
||||
public async Task<string> StoreMailAsync(string dataSourceId, string name, string subject, string text, MailRecord mail)
|
||||
{
|
||||
var key = $"mail:{name}";
|
||||
var mailId = IndexedDocumentIds.CreateParentId(dataSourceId, key);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await this.Client.UpsertFileAsync(dataSourceId, new EmbeddingStateFile(mailId, key, subject, key, "mail", name, 2048, now, now, now, 1), CancellationToken.None);
|
||||
await this.Client.UpsertChunksAsync(dataSourceId, [new EmbeddingStateChunk(IndexedDocumentIds.CreateChunkId(dataSourceId, name, 0), mailId, null, 0, text, now)], CancellationToken.None);
|
||||
await this.Client.UpsertMailAsync(dataSourceId, mail with { MailId = mailId }, CancellationToken.None);
|
||||
return mailId;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// The pooled connections hold the file open, which would keep it from being deleted on Windows:
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(directory, true);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user