diff --git a/REUSE.toml b/REUSE.toml index a3010ce..7546236 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -7,3 +7,8 @@ version = 1 path = "flake.lock" SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = "src/testdata/*.eml" +SPDX-FileCopyrightText = "© 2024 Jeffrey C. Ollie" +SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/src/notmuch.zig b/src/notmuch.zig index 2f32011..6ee63f3 100644 --- a/src/notmuch.zig +++ b/src/notmuch.zig @@ -48,4 +48,5 @@ pub fn builtWith(name: [:0]const u8) bool { test { std.testing.refAllDecls(@This()); _ = @import("compile_check.zig"); + _ = @import("testing.zig"); } diff --git a/src/testdata/four.eml b/src/testdata/four.eml new file mode 100644 index 0000000..e4aeb20 --- /dev/null +++ b/src/testdata/four.eml @@ -0,0 +1,7 @@ +From: Dave +To: Alice +Subject: Zig bindings +Date: Wed, 03 Apr 2024 08:15:00 +0000 +Message-ID: + +Have you tried the notmuch Zig bindings yet? diff --git a/src/testdata/one.eml b/src/testdata/one.eml new file mode 100644 index 0000000..9ca5d39 --- /dev/null +++ b/src/testdata/one.eml @@ -0,0 +1,7 @@ +From: Alice +To: Bob +Subject: Lunch plans +Date: Mon, 01 Apr 2024 09:00:00 +0000 +Message-ID: + +Want to grab lunch at noon? diff --git a/src/testdata/three.eml b/src/testdata/three.eml new file mode 100644 index 0000000..06e52d0 --- /dev/null +++ b/src/testdata/three.eml @@ -0,0 +1,7 @@ +From: Carol +To: Alice +Subject: Quarterly report +Date: Tue, 02 Apr 2024 14:00:00 +0000 +Message-ID: + +The quarterly report is attached. (Not really.) diff --git a/src/testdata/two.eml b/src/testdata/two.eml new file mode 100644 index 0000000..99ee89f --- /dev/null +++ b/src/testdata/two.eml @@ -0,0 +1,9 @@ +From: Bob +To: Alice +Subject: Re: Lunch plans +Date: Mon, 01 Apr 2024 09:30:00 +0000 +Message-ID: +In-Reply-To: +References: + +Sure, see you at the usual place. diff --git a/src/testing.zig b/src/testing.zig new file mode 100644 index 0000000..124fb44 --- /dev/null +++ b/src/testing.zig @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: © 2024 Jeffrey C. Ollie +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Test support: builds a throwaway notmuch database from a small embedded +//! corpus of email messages, so unit tests can exercise the bindings against +//! real indexed mail. Only usable from tests — `std.testing.tmpDir` asserts +//! that it is running in a test build. + +const std = @import("std"); + +const Database = @import("Database.zig"); + +pub const CorpusMessage = struct { + filename: []const u8, + data: []const u8, +}; + +/// A small fixed corpus: a two-message thread and two standalone messages. +pub const corpus = [_]CorpusMessage{ + .{ .filename = "one.eml", .data = @embedFile("testdata/one.eml") }, + .{ .filename = "two.eml", .data = @embedFile("testdata/two.eml") }, + .{ .filename = "three.eml", .data = @embedFile("testdata/three.eml") }, + .{ .filename = "four.eml", .data = @embedFile("testdata/four.eml") }, +}; + +pub const TestDatabase = struct { + tmp: std.testing.TmpDir, + database: Database, + + pub fn deinit(self: *TestDatabase) void { + self.database.deinit() catch {}; + self.tmp.cleanup(); + } +}; + +/// Create a notmuch database in a temporary directory and index the embedded +/// corpus into it. The database is open in read-write mode. Callers own the +/// result and must call `deinit` to close the database and remove the +/// temporary directory. +pub fn corpusDatabase() !TestDatabase { + const alloc = std.testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + + try tmp.dir.createDir(io, "mail", .default_dir); + try tmp.dir.createDir(io, "mail/cur", .default_dir); + try tmp.dir.createDir(io, "mail/new", .default_dir); + try tmp.dir.createDir(io, "mail/tmp", .default_dir); + + var dir_name_buf: [std.fs.max_path_bytes]u8 = undefined; + const len = try tmp.dir.realPath(io, &dir_name_buf); + const dir_name = dir_name_buf[0..len]; + + const database_path = try std.fs.path.joinZ(alloc, &.{ dir_name, "mail" }); + defer alloc.free(database_path); + + { + var cfg = try tmp.dir.createFile(io, "config", .{}); + defer cfg.close(io); + + var buf: [64]u8 = undefined; + var file_writer = cfg.writer(io, &buf); + const writer = &file_writer.interface; + + try writer.print( + \\[database] + \\path={s} + \\mail_root={s} + \\[user] + \\primary_email=zig@example.org + \\[new] + \\[search] + \\[maildir] + \\ + , + .{ database_path, database_path }, + ); + try writer.flush(); + } + + for (corpus) |message| { + const sub_path = try std.fs.path.join(alloc, &.{ "mail", "cur", message.filename }); + defer alloc.free(sub_path); + try tmp.dir.writeFile(io, .{ .sub_path = sub_path, .data = message.data }); + } + + const config_path = try std.fs.path.joinZ(alloc, &.{ dir_name, "config" }); + defer alloc.free(config_path); + + const database = switch (Database.create(.{ .config_path = config_path })) { + .ok => |database| database, + .err => |e| { + defer e.deinit(); + return e.err; + }, + }; + errdefer database.deinit() catch {}; + + for (corpus) |message| { + const path = try std.fs.path.joinZ(alloc, &.{ dir_name, "mail", "cur", message.filename }); + defer alloc.free(path); + try database.indexFile(path, null); + } + + return .{ .tmp = tmp, .database = database }; +} + +test corpusDatabase { + var test_db = try corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const query = try db.queryCreate("*"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, corpus.len), try query.countMessages()); + try std.testing.expectEqual(@as(u32, 3), try query.countThreads()); + + const message = try db.findMessage("two@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + try std.testing.expectEqualStrings( + "Re: Lunch plans", + message.getHeader("Subject") orelse return error.TestUnexpectedResult, + ); +}