Skip to content
90 changes: 81 additions & 9 deletions QuickMail.Tests/GraphServerRuleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,65 @@ public async Task List_UnsupportedPredicate_MarksRuleViewOnly()
var handler = new RecordingHandler(Json(Collection(
"""
{ "id": "r1", "displayName": "Complex", "sequence": 1, "isEnabled": true,
"conditions": { "subjectContains": ["x"], "bodyContains": ["secret"] },
"conditions": { "subjectContains": ["x"], "headerContains": ["X-Spam"] },
"actions": { "markAsRead": true } }
""")));

var rule = (await Service(handler).ListAsync(_accountId)).Single();

Assert.False(rule.IsFullyEditable);
Assert.Contains("body contains", rule.UnsupportedFields);
Assert.Contains("header contains", rule.UnsupportedFields);
}

[Fact]
public async Task List_BodyContainsAndSentToAddresses_AreEditable_AndRoundTrip()
{
// #333: these two conditions were added to the supported subset (they cover every rule that
// was previously flagged on a real O365 mailbox). Read them, and prove Create writes them
// back so an edit can't drop them.
var handler = new RecordingHandler(
Json(Collection(
"""
{ "id": "r1", "displayName": "Boards", "sequence": 1, "isEnabled": true,
"conditions": { "subjectContains": ["Agenda"], "bodyContains": ["PTAC"],
"sentToAddresses": [ { "emailAddress": { "address": "board@contoso.com" } } ] },
"actions": { "moveToFolder": "F", "stopProcessingRules": true } }
""")),
Json("""{ "id": "new", "displayName": "Boards" }"""));
var svc = Service(handler);

var rule = (await svc.ListAsync(_accountId)).Single();
Assert.True(rule.IsFullyEditable); // no longer flagged
Assert.Empty(rule.UnsupportedFields);
Assert.Equal("PTAC", rule.BodyContains);
Assert.Equal("board@contoso.com", Assert.Single(rule.SentToAddresses));

await svc.CreateAsync(_accountId, rule);
var body = handler.Bodies.Last()!;
Assert.Contains("bodyContains", body);
Assert.Contains("sentToAddresses", body);
Assert.Contains("board@contoso.com", body);
}

[Fact]
public async Task List_CopyToFolder_IsEditable_AndRoundTrips()
{
var handler = new RecordingHandler(
Json(Collection(
"""
{ "id": "r1", "displayName": "Archive copy", "sequence": 1, "isEnabled": true,
"conditions": { "subjectContains": ["x"] },
"actions": { "copyToFolder": "FolderZ" } }
""")),
Json("""{ "id": "new" }"""));
var svc = Service(handler);

var rule = (await svc.ListAsync(_accountId)).Single();
Assert.True(rule.IsFullyEditable);
Assert.Equal("FolderZ", rule.CopyToFolderId);

await svc.CreateAsync(_accountId, rule);
Assert.Contains("copyToFolder", handler.Bodies.Last()!);
}

[Fact]
Expand All @@ -163,6 +214,7 @@ public async Task List_UnsupportedAction_MarksRuleViewOnly()
[Theory]
[InlineData("subjectContains")]
[InlineData("senderContains")]
[InlineData("bodyContains")]
[InlineData("bodyOrSubjectContains")]
public async Task List_MultiValueStringPredicate_IsViewOnly(string predicate)
{
Expand Down Expand Up @@ -370,20 +422,30 @@ public async Task SetEnabled_SendsOnlyIsEnabled_SoOtherFieldsSurvive()
}

[Fact]
public async Task Reorder_AssignsSequentialPositions()
public async Task Reorder_PatchesOnlyChangedRules_LeavingUntouchedRulesAlone()
{
var handler = new RecordingHandler(Json("{}"), Json("{}"), Json("{}"));
// Rules a=1, b=2, c=3. Swap the first two → [b, a, c]. Only b and a change position;
// c keeps sequence 3 and must NOT be PATCHed. This is what stops a server-protected rule
// elsewhere in the list (which 400s on any PATCH) from poisoning an unrelated move.
var a = new ServerRuleModel { Id = "a", Sequence = 1 };
var b = new ServerRuleModel { Id = "b", Sequence = 2 };
var c = new ServerRuleModel { Id = "c", Sequence = 3 };
var handler = new RecordingHandler(Json("{}"), Json("{}"));

await Service(handler).ReorderAsync(_accountId, ["c", "a", "b"]);
await Service(handler).ReorderAsync(_accountId, new[] { b, a, c });

Assert.Equal(3, handler.Urls.Count);
Assert.Equal(2, handler.Urls.Count);
Assert.All(handler.Methods, m => Assert.Equal("PATCH", m));
Assert.EndsWith("/messageRules/c", handler.Urls[0]);
Assert.EndsWith("/messageRules/b", handler.Urls[0]);
Assert.Contains("\"sequence\":1", handler.Bodies[0]!);
Assert.EndsWith("/messageRules/a", handler.Urls[1]);
Assert.Contains("\"sequence\":2", handler.Bodies[1]!);
Assert.EndsWith("/messageRules/b", handler.Urls[2]);
Assert.Contains("\"sequence\":3", handler.Bodies[2]!);
Assert.DoesNotContain(handler.Urls, u => u.EndsWith("/messageRules/c"));

// Local models reflect the sequence values the server was told.
Assert.Equal(1, b.Sequence);
Assert.Equal(2, a.Sequence);
Assert.Equal(3, c.Sequence);
}

[Fact]
Expand Down Expand Up @@ -444,6 +506,16 @@ public void ToString_CarriesNameStateAndSummary()
Assert.DoesNotContain("ServerRuleModel", text); // never the type name
}

[Fact]
public void ToString_MarksNotEditableRules_SoTheListRowAnnouncesIt()
{
var editable = new ServerRuleModel { DisplayName = "Simple", SubjectContains = "x", MarkAsRead = true };
var notEditable = new ServerRuleModel { DisplayName = "Complex", IsFullyEditable = false, UnsupportedFields = ["body contains"] };

Assert.DoesNotContain("not editable", editable.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("not editable in QuickMail", notEditable.ToString(), StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void DetailText_ExplainsWhyARuleIsNotEditable()
{
Expand Down
Loading
Loading