From e2ec7263a65a44beb807374e8ec1a136bf5c3d88 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:15:19 +0200 Subject: test(rulequery): pin whole-query rejection --- tests/test_rulequery.cpp | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 88485f3..7a34403 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -48,6 +48,8 @@ private slots: void aNegatedTermParsesAsANegatedOperator(); void whatParsesCompilesBackUnchanged(); void anOrGroupWithExclusionsParses(); + void anUnrepresentableQueryRejectsWhole(); + void aParenBearingValueIsARowNotAShape(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -324,5 +326,51 @@ void TestRuleQuery::anOrGroupWithExclusionsParses() "and not subject:receipt and not subject:refund")); } +void TestRuleQuery::anUnrepresentableQueryRejectsWhole() +{ + const QStringList unrepresentable = { + QStringLiteral("from:a.example.org or (from:b.example.org " + "or from:c.example.org)"), // nested or inside or + QStringLiteral("from:a.example.org and subject:x " + "or subject:y"), // mixed, unparenthesised + QStringLiteral("body:receipt"), // unrecognised prefix + QStringLiteral("folder:Inbox"), // not the prefix we emit + QStringLiteral("receipt"), // bare word, no prefix + QStringLiteral("path:\"account-one\""), // no /** suffix + QStringLiteral("from:a.example.org and"), // trailing operator + QStringLiteral("date:2026-01-01..2026-02-01"), // two-sided range + QStringLiteral("from:a.example.org xor subject:x"), + QStringLiteral("subject:\"unterminated"), // open quote + QStringLiteral("(from:a.example.org or from:b.example.org)"), + // group, nothing after it + QStringLiteral("(from:a.example.org and from:b.example.org) " + "and not subject:x"), // group joined by and + }; + + for (const QString &query : unrepresentable) { + const RuleQuery q = RuleQuery::parse(query); + QVERIFY2(!q.parsed, qPrintable(QStringLiteral("parsed: ") + query)); + // Rejecting whole means keeping nothing: a half-parse is how a + // negation goes missing and a rule quietly matches more mail. + QVERIFY2(q.terms.isEmpty() && q.exclusions.isEmpty(), + qPrintable(QStringLiteral("kept rows: ") + query)); + } +} + +void TestRuleQuery::aParenBearingValueIsARowNotAShape() +{ + // notmuch reads these parens as characters to search for, not as + // grouping, so the query is meaningful and matches nothing. It parses + // here as a From row holding that literal text, which is what it means. + // The property to hold is the round trip, NOT a rejection: a test + // expecting an error here fails against correct code. + const RuleQuery q = RuleQuery::parse(QStringLiteral("from:((((")); + + QVERIFY(q.parsed); + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.terms.at(0).value, QStringLiteral("((((")); + QCOMPARE(q.compile(), QStringLiteral("from:((((")); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3