summaryrefslogtreecommitdiffstats
path: root/tests/test_rulequery.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_rulequery.cpp')
-rw-r--r--tests/test_rulequery.cpp415
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"