diff options
Diffstat (limited to 'tests/test_rulequery.cpp')
| -rw-r--r-- | tests/test_rulequery.cpp | 415 |
1 files changed, 415 insertions, 0 deletions
diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp new file mode 100644 index 0000000..54f9477 --- /dev/null +++ b/tests/test_rulequery.cpp @@ -0,0 +1,415 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <QtTest> + +#include "rulequery.h" + +/// RuleQuery is a view over a string the notmuch post-new hook executes, so +/// the risk is a query that compiles to something subtly wider than the rows +/// say. These tests are about exact strings, not about whether notmuch would +/// accept the result: notmuch accepts almost anything, including `from:((((`. +class TestRuleQuery : public QObject +{ + Q_OBJECT + +private slots: + void aSingleContainsTermCompiles(); + void everyFieldCompilesToItsPrefix(); + void isQuotesAndContainsDoesNot(); + void negationPrefixesNot(); + void aValueWithASpaceIsAlwaysQuoted(); + void folderAppendsTheRecursiveSuffix(); + void dateCompilesToAOneSidedRange(); + void allJoinsWithAnd(); + void anyJoinsWithOr(); + void exclusionsAppendAsAndNot(); + void anyIsParenthesisedOnlyWhenExclusionsFollow(); + void anEmptyQueryCompilesToAnEmptyString(); + void aFlatAndChainParses(); + void aFlatOrChainParses(); + void anEmptyQueryParsesToNoRows(); + void quotedValuesLoseTheirQuotes(); + void aNegatedTermParsesAsANegatedOperator(); + void whatParsesCompilesBackUnchanged(); + void anOrGroupWithExclusionsParses(); + void anUnrepresentableQueryRejectsWhole(); + void aParenBearingValueIsARowNotAShape(); + void theRuleCorpusRoundTripsByteForByte(); +}; + +void TestRuleQuery::aSingleContainsTermCompiles() +{ + RuleQuery q; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("sender@example.org")}); + + QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org")); +} + +void TestRuleQuery::everyFieldCompilesToItsPrefix() +{ + const QVector<QPair<RuleTerm::Field, QString>> cases = { + {RuleTerm::From, QStringLiteral("from:x")}, + {RuleTerm::To, QStringLiteral("to:x")}, + {RuleTerm::Cc, QStringLiteral("cc:x")}, + {RuleTerm::Subject, QStringLiteral("subject:x")}, + }; + + for (const auto &c : cases) { + RuleQuery q; + q.terms.append({c.first, RuleTerm::Contains, QStringLiteral("x")}); + QCOMPARE(q.compile(), c.second); + } +} + +void TestRuleQuery::isQuotesAndContainsDoesNot() +{ + RuleQuery contains; + contains.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + QCOMPARE(contains.compile(), QStringLiteral("subject:receipt")); + + RuleQuery is; + is.terms.append({RuleTerm::Subject, RuleTerm::Is, + QStringLiteral("receipt")}); + QCOMPARE(is.compile(), QStringLiteral("subject:\"receipt\"")); +} + +void TestRuleQuery::negationPrefixesNot() +{ + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::ContainsNot, + QStringLiteral("receipt")}); + QCOMPARE(q.compile(), QStringLiteral("not subject:receipt")); + + RuleQuery tag; + tag.terms.append({RuleTerm::Tag, RuleTerm::IsNot, + QStringLiteral("inbox")}); + QCOMPARE(tag.compile(), QStringLiteral("not tag:inbox")); + + RuleQuery att; + att.terms.append({RuleTerm::Attachment, RuleTerm::HasNot, + QStringLiteral("pdf")}); + QCOMPARE(att.compile(), QStringLiteral("not attachment:pdf")); +} + +void TestRuleQuery::aValueWithASpaceIsAlwaysQuoted() +{ + // Unquoted, a space would end the term and the rest would become a + // separate bare word, silently widening the rule. + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("your receipt")}); + QCOMPARE(q.compile(), QStringLiteral("subject:\"your receipt\"")); +} + +void TestRuleQuery::folderAppendsTheRecursiveSuffix() +{ + // A path: without the suffix matches nothing, and notmuch reports no + // error when it happens. + RuleQuery q; + q.terms.append({RuleTerm::Folder, RuleTerm::Is, + QStringLiteral("account-one")}); + QCOMPARE(q.compile(), QStringLiteral("path:\"account-one/**\"")); +} + +void TestRuleQuery::dateCompilesToAOneSidedRange() +{ + RuleQuery before; + before.terms.append({RuleTerm::Date, RuleTerm::Before, + QStringLiteral("2026-01-01")}); + QCOMPARE(before.compile(), QStringLiteral("date:..2026-01-01")); + + RuleQuery after; + after.terms.append({RuleTerm::Date, RuleTerm::After, + QStringLiteral("2026-01-01")}); + QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01..")); +} + +void TestRuleQuery::allJoinsWithAnd() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org and subject:receipt")); +} + +void TestRuleQuery::anyJoinsWithOr() +{ + RuleQuery q; + q.join = RuleQuery::Any; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::exclusionsAppendAsAndNot() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org " + "and not subject:receipt")); +} + +void TestRuleQuery::anyIsParenthesisedOnlyWhenExclusionsFollow() +{ + // Without the parens this binds as (a or (b and not c)), which matches + // everything from the first sender regardless of the exclusion. + RuleQuery guarded; + guarded.join = RuleQuery::Any; + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + guarded.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(guarded.compile(), + QStringLiteral("(from:one.example.org or from:two.example.org) " + "and not subject:receipt")); + + // No exclusion, no parens: they would be noise in the stored file. + RuleQuery bare; + bare.join = RuleQuery::Any; + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(bare.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString() +{ + RuleQuery q; + QCOMPARE(q.compile(), QString()); +} + +void TestRuleQuery::aFlatAndChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and subject:receipt")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::All); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.terms.at(0).field, RuleTerm::From); + QCOMPARE(q.terms.at(0).op, RuleTerm::Contains); + QCOMPARE(q.terms.at(0).value, QStringLiteral("vendor.example.org")); + QCOMPARE(q.terms.at(1).field, RuleTerm::Subject); + QVERIFY(q.exclusions.isEmpty()); +} + +void TestRuleQuery::aFlatOrChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:one.example.org or from:two.example.org")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); +} + +void TestRuleQuery::anEmptyQueryParsesToNoRows() +{ + // One shipped rule has an empty query. It must open in the builder ready + // to receive a row, not fall back to text mode. + const RuleQuery q = RuleQuery::parse(QString()); + + QVERIFY(q.parsed); + QVERIFY(q.terms.isEmpty()); +} + +void TestRuleQuery::quotedValuesLoseTheirQuotes() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("subject:\"your receipt\"")); + + QVERIFY(q.parsed); + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.terms.at(0).value, QStringLiteral("your receipt")); + QCOMPARE(q.terms.at(0).op, RuleTerm::Is); +} + +void TestRuleQuery::aNegatedTermParsesAsANegatedOperator() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and not tag:inbox")); + + QVERIFY(q.parsed); + // A trailing negation on an `and` chain becomes an exclusion: that is how + // the user describes these rules, and the design records the preference. + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.exclusions.size(), 1); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Tag); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Is); +} + +void TestRuleQuery::whatParsesCompilesBackUnchanged() +{ + // The dialog decides whether to rewrite the stored string by comparing + // against what it parsed, so a compile that differs by so much as a quote + // would churn a file a second tool reads. + const QStringList queries = { + QStringLiteral("from:vendor.example.org"), + QStringLiteral("from:vendor.example.org and subject:receipt"), + QStringLiteral("from:one.example.org or from:two.example.org"), + QStringLiteral("subject:\"your receipt\""), + QStringLiteral("path:\"account-one/**\""), + QStringLiteral("tag:inbox"), + QStringLiteral("attachment:pdf"), + QStringLiteral("date:..2026-01-01"), + QStringLiteral("date:2026-01-01.."), + QStringLiteral("from:vendor.example.org and not tag:inbox"), + QStringLiteral("from:vendor.example.org and not subject:receipt " + "and not subject:refund"), + QStringLiteral("(from:one.example.org or from:two.example.org) " + "and not subject:receipt"), + }; + + for (const QString &query : queries) { + const RuleQuery parsed = RuleQuery::parse(query); + QVERIFY2(parsed.parsed, qPrintable(query)); + QCOMPARE(parsed.compile(), query); + } +} + +void TestRuleQuery::anOrGroupWithExclusionsParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt and not subject:refund")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.exclusions.size(), 2); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Subject); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Contains); + QCOMPARE(q.exclusions.at(0).value, QStringLiteral("receipt")); + + // The round trip is the point: this must come back as it went in. + QCOMPARE(q.compile(), + QStringLiteral("(from:vendor.example.org or " + "from:vendor.example.net) " + "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:((((")); +} + +void TestRuleQuery::theRuleCorpusRoundTripsByteForByte() +{ + // Every query SHAPE present in a real rules file, with placeholder + // values. Verified by hand against the live file when this was written: + // all seventeen rules parsed and compiled back byte for byte, none fell + // to text mode. + // + // A compile that differs by so much as a paren would rewrite the shared + // file on the next save, which the companion tool then sees as a diff + // nobody made. + const QStringList corpus = { + QString(), // a rule with no query yet + QStringLiteral("path:\"account-one/**\""), + QStringLiteral("path:\"account-two/Inbox/topic/**\""), + QStringLiteral("subject:\"[list-name]\""), + QStringLiteral("from:notifications@service.example.org"), + QStringLiteral("from:one@jobs.example.org or " + "from:two@jobs.example.org or " + "from:three@jobs.example.org"), + QStringLiteral("from:mail.vendor.example.org and " + "subject:\"Secure link\""), + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt and not subject:refund " + "and not subject:EUR"), + }; + + for (const QString &query : corpus) { + const RuleQuery parsed = RuleQuery::parse(query); + QVERIFY2(parsed.parsed, qPrintable(query)); + QCOMPARE(parsed.compile(), query); + + // And the value itself round-trips, which is what the dialog's + // "was this edited" comparison depends on. + QVERIFY2(RuleQuery::parse(parsed.compile()) == parsed, + qPrintable(query)); + } +} + +QTEST_MAIN(TestRuleQuery) +#include "test_rulequery.moc" |
