diff options
Diffstat (limited to 'tests/test_tagrules.cpp')
| -rw-r--r-- | tests/test_tagrules.cpp | 245 |
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" |
