aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_tagrules.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-12 11:47:40 +0200
committerDanilo M. <danix@danix.xyz>2026-08-12 11:47:40 +0200
commitd14c0224826f821b34c894037ab668ddfde29a6d (patch)
tree656b614f08b3a9eed9771ac4374e0d972a15eece /tests/test_tagrules.cpp
parent49dac862685a50e1def6646349b61f4b36e73cc8 (diff)
downloadqtmaildir-d14c0224826f821b34c894037ab668ddfde29a6d.tar.gz
qtmaildir-d14c0224826f821b34c894037ab668ddfde29a6d.zip
feat(rules): read and write the shared rule store
The same ~/.config/mailrules/rules.json mailctl reads, parsed here with QJsonDocument and written atomically with QSaveFile. Fields this version does not understand round-trip untouched, which is what keeps the format neutral between the two tools. Mutation-checked: removing the unknown-field write fails unknownFieldsSurviveASave.
Diffstat (limited to 'tests/test_tagrules.cpp')
-rw-r--r--tests/test_tagrules.cpp245
1 files changed, 245 insertions, 0 deletions
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
new file mode 100644
index 0000000..8c7e1a9
--- /dev/null
+++ b/tests/test_tagrules.cpp
@@ -0,0 +1,245 @@
+/*
+ * 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 <QTemporaryDir>
+#include <QtTest>
+
+#include "tagrules.h"
+
+/// The risk in TagRules is the format, not painting: a field silently dropped
+/// on save mis-tags real mail on the next sync, and does it quietly. These
+/// tests are therefore about round-trips and rejections, and the file they
+/// read is byte-for-byte what mailctl writes.
+class TestTagRules : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aRuleLoadsWithEveryField();
+ void absentFieldsTakeTheirDefaults();
+ void aMalformedRuleIsDroppedWithAWarning();
+ void unknownFieldsSurviveASave();
+ void stageOrderPutsAccountsFirst();
+ void aQueryWithQuotesRoundTrips();
+ void aMissingFileIsEmptyNotAnError();
+ void aNewerVersionIsRefused();
+
+private:
+ QString writeRules(const QString &json);
+ QTemporaryDir m_dir;
+};
+
+QString TestTagRules::writeRules(const QString &json)
+{
+ const QString path = m_dir.filePath(QStringLiteral("rules.json"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
+ return QString();
+ file.write(json.toUtf8());
+ file.close();
+ return path;
+}
+
+void TestTagRules::aRuleLoadsWithEveryField()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{
+ "id": "notify-forge",
+ "stage": 50,
+ "enabled": true,
+ "add": ["notify/forge"],
+ "remove": [],
+ "query": "from:notifications@example.com",
+ "note": "All repositories, not one project."
+ }]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QVERIFY2(rules.warnings().isEmpty(),
+ qPrintable(rules.warnings().join(QStringLiteral("; "))));
+ QCOMPARE(rules.rules().size(), 1);
+
+ const TagRule &rule = rules.rules().first();
+ QCOMPARE(rule.id, QStringLiteral("notify-forge"));
+ QCOMPARE(rule.stage, 50);
+ QVERIFY(rule.enabled);
+ QCOMPARE(rule.add, QStringList{ QStringLiteral("notify/forge") });
+ QVERIFY(rule.remove.isEmpty());
+ QCOMPARE(rule.query, QStringLiteral("from:notifications@example.com"));
+ QCOMPARE(rule.note, QStringLiteral("All repositories, not one project."));
+}
+
+void TestTagRules::absentFieldsTakeTheirDefaults()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{"id": "minimal", "add": ["x"],
+ "query": "from:a@example.com"}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QVERIFY(rules.warnings().isEmpty());
+ const TagRule &rule = rules.rules().first();
+ QCOMPARE(rule.stage, 50);
+ QVERIFY(rule.enabled);
+ QVERIFY(rule.remove.isEmpty());
+ QVERIFY(rule.note.isEmpty());
+}
+
+void TestTagRules::aMalformedRuleIsDroppedWithAWarning()
+{
+ // One bad rule must not cost the others. Four separate defects, and the
+ // good rule sits first so a parser that stops at the first problem is
+ // caught by the count rather than by an empty list.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "good", "add": ["x"], "query": "from:a@example.com"},
+ {"id": "no-query", "add": ["y"]},
+ {"id": "no-tags", "query": "from:b@example.com"},
+ {"id": "Bad Id", "add": ["z"], "query": "from:c@example.com"}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QCOMPARE(rules.rules().size(), 1);
+ QCOMPARE(rules.rules().first().id, QStringLiteral("good"));
+ QCOMPARE(rules.warnings().size(), 3);
+}
+
+void TestTagRules::unknownFieldsSurviveASave()
+{
+ // The neutrality guarantee. If qtmaildir strips a field mailctl wrote,
+ // the file is qtmaildir's file that mailctl may read.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "future_top_level": {"set_by": "another tool"},
+ "rules": [{
+ "id": "keeper",
+ "add": ["x"],
+ "query": "from:a@example.com",
+ "future_field": [1, 2, 3]
+ }]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QVERIFY(rules.save(path));
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QJsonObject root =
+ QJsonDocument::fromJson(file.readAll()).object();
+
+ QCOMPARE(root.value(QStringLiteral("future_top_level"))
+ .toObject().value(QStringLiteral("set_by")).toString(),
+ QStringLiteral("another tool"));
+
+ const QJsonObject saved =
+ root.value(QStringLiteral("rules")).toArray().first().toObject();
+ QCOMPARE(saved.value(QStringLiteral("future_field")).toArray().size(), 3);
+ QCOMPARE(saved.value(QStringLiteral("id")).toString(),
+ QStringLiteral("keeper"));
+}
+
+void TestTagRules::stageOrderPutsAccountsFirst()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "topic-b", "stage": 50, "add": ["b"],
+ "query": "from:b@example.com"},
+ {"id": "account", "stage": 10, "add": ["acct"],
+ "query": "path:\"work/**\""},
+ {"id": "topic-a", "stage": 50, "add": ["a"],
+ "query": "from:a@example.com"},
+ {"id": "off", "stage": 20, "add": ["c"],
+ "query": "from:c@example.com", "enabled": false}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QStringList ids;
+ for (const TagRule &rule : rules.ordered())
+ ids.append(rule.id);
+
+ // Stage ascending, ties in file order, disabled excluded.
+ QCOMPARE(ids, (QStringList{ QStringLiteral("account"),
+ QStringLiteral("topic-b"),
+ QStringLiteral("topic-a") }));
+ // Still loaded, so the dialog can show and re-enable it.
+ QCOMPARE(rules.rules().size(), 4);
+}
+
+void TestTagRules::aQueryWithQuotesRoundTrips()
+{
+ // Not hypothetical: every account rule is written path:"account/**".
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{"id": "account", "add": ["acct"],
+ "query": "path:\"work-account/**\""}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QCOMPARE(rules.rules().first().query,
+ QStringLiteral("path:\"work-account/**\""));
+
+ QVERIFY(rules.save(path));
+
+ TagRules reloaded;
+ reloaded.load(path);
+ QVERIFY(reloaded.warnings().isEmpty());
+ QCOMPARE(reloaded.rules().first().query,
+ QStringLiteral("path:\"work-account/**\""));
+}
+
+void TestTagRules::aMissingFileIsEmptyNotAnError()
+{
+ // qtmaildir must open on a machine that has never written this file.
+ TagRules rules;
+ rules.load(m_dir.filePath(QStringLiteral("absent.json")));
+ QVERIFY(rules.rules().isEmpty());
+ QVERIFY(rules.warnings().isEmpty());
+ QVERIFY(rules.missing());
+}
+
+void TestTagRules::aNewerVersionIsRefused()
+{
+ const QString path = writeRules(R"({
+ "version": 2,
+ "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QVERIFY(rules.rules().isEmpty());
+ QCOMPARE(rules.warnings().size(), 1);
+}
+
+QTEST_MAIN(TestTagRules)
+#include "test_tagrules.moc"