fix(categories): correct all seven smart-folder bugs at the source (PHASE 2)
CI / backend (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 52s
CI / db-tests (pull_request) Successful in 57s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 57s
Security / sast (pull_request) Successful in 37s

Root causes + fixes:
- Archive leaked Sent mail: filter was just 'not in inbox'. Added ExcludeGmailLabels;
  Archive now excludes SENT/DRAFT/SPAM/TRASH/CHAT by label.
- Read Later / Pinned / Unlabelled returned EVERYTHING (no folder case -> default all).
  Pinned = IsImportant; Read Later = new local IsReadLater marker (+toggle endpoints);
  Unlabelled = HasUserLabels=false.
- Trash & Spam always empty: IncludeSpamTrash=false + sync never set IsTrashed nor created
  EmailLabel rows. Now fetches spam/trash, sets IsTrashed from the TRASH label, and links
  every message to its Gmail labels (also fixes Sent/Spam/category-by-label/Unlabelled).
- Old Mail: now strictly older than 6 months (was 1 year).

Guardrail: sync upsert is now idempotent (drops prior copy before reinsert) so re-sync
can't duplicate a message or leave stale labels. 4 new filter tests; 65 backend + frontend
green; migration is a single non-destructive column add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-04 16:21:53 +02:00
parent 3333e19452
commit 654acacf5f
12 changed files with 1084 additions and 6 deletions
@@ -0,0 +1,115 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// PHASE 2 (smart-category correctness): each reported folder bug — Archive leaking Sent,
/// Read Later / Pinned / Unlabelled returning everything, Old Mail duration — is fixed at the
/// filter source. These prove the SearchService filters that back the corrected folder mappings.
/// </summary>
public class CategoryFilterTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
private static AppDbContext Db(string name, Guid uid) =>
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options,
new FakeCurrentUser { UserId = uid });
// A small fixture: an inbox email, a sent email (SENT label), one important, one read-later,
// and one filed under a user label.
private static async Task<Guid> SeedAsync(string name)
{
var uid = Guid.NewGuid();
using var db = Db(name, Guid.Empty);
var sender = new Sender { UserId = uid, Address = "s@x.x" };
db.Senders.Add(sender);
var sentLabel = new Label { UserId = uid, GmailLabelId = "SENT", Name = "Sent", Type = "system" };
var userLabel = new Label { UserId = uid, GmailLabelId = "Label_1", Name = "Projects", Type = "user" };
db.Labels.AddRange(sentLabel, userLabel);
Email E(string id, Action<Email> cfg)
{
var e = new Email { UserId = uid, GmailMessageId = id, Subject = id, Sender = sender, SentAtUtc = DateTimeOffset.UtcNow };
cfg(e);
db.Emails.Add(e);
return e;
}
var inbox = E("inbox", e => e.IsInInbox = true);
var sent = E("sent", e => e.IsInInbox = false); // archived-looking, but it's Sent
var important = E("important", e => e.IsImportant = true);
var later = E("later", e => e.IsReadLater = true);
var filed = E("filed", e => e.IsInInbox = false);
await db.SaveChangesAsync();
db.Set<EmailLabel>().AddRange(
new EmailLabel { EmailId = sent.Id, LabelId = sentLabel.Id },
new EmailLabel { EmailId = filed.Id, LabelId = userLabel.Id });
await db.SaveChangesAsync();
return uid;
}
private static async Task<List<string>> RunAsync(string name, Guid uid, SearchRequestDto req)
{
using var db = Db(name, uid);
var res = await new SearchService(db).SearchAsync(uid, req);
return res.Items.Select(i => i.GmailMessageId).OrderBy(x => x).ToList();
}
private static SearchRequestDto Base() => new(null, null, null, null, null, null, null, false, 1, 50);
[Fact]
public async Task Archive_excludes_sent_mail()
{
var name = nameof(Archive_excludes_sent_mail);
var uid = await SeedAsync(name);
// Archive: not-in-inbox, not-trashed, excluding SENT — must NOT contain "sent".
var items = await RunAsync(name, uid, Base() with
{
IsInInbox = false,
IsTrashed = false,
ExcludeGmailLabels = new[] { "SENT", "SPAM", "DRAFT" }
});
items.Should().NotContain("sent");
items.Should().Contain("filed"); // a genuinely archived, user-filed mail stays
}
[Fact]
public async Task Pinned_returns_only_important_not_everything()
{
var name = nameof(Pinned_returns_only_important_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsImportant = true });
items.Should().Equal("important");
}
[Fact]
public async Task ReadLater_returns_only_flagged_not_everything()
{
var name = nameof(ReadLater_returns_only_flagged_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsReadLater = true });
items.Should().Equal("later");
}
[Fact]
public async Task Unlabelled_excludes_user_labelled_mail()
{
var name = nameof(Unlabelled_excludes_user_labelled_mail);
var uid = await SeedAsync(name);
// "filed" has a user label → must be absent; everything else (no user label) present.
var items = await RunAsync(name, uid, Base() with { HasUserLabels = false });
items.Should().NotContain("filed");
items.Should().Contain("inbox");
}
}