Something went wrong. Try again.
A Zig client for the Paperless-ngx REST API, with streaming uploads, task waiting and a command-line tool.
Something went wrong. Try again.
16 kB · 390 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Against a real Paperless-ngx.//!//! `tests/client.zig` checks the client against a fake that answers the way//! Paperless does; this checks that the fake was right. It runs only when it//! is pointed at a server, by the same two variables the command-line tool//! reads://!//! ```console//! $ PAPERLESS_URL=http://127.0.0.1:8000 PAPERLESS_TOKEN=... zig build live-test//! ```//!//! and `nix flake check` does exactly that against Paperless-ngx in a//! virtual machine. Everything it makes is named with a random suffix and//! removed again, so it can be pointed at a server that is in use -- though//! a test server is the better idea, since a failure part way through leaves//! its objects behind.
const std = @import("std");const Io = std.Io;const testing = std.testing;
const paperless = @import("paperless");
const gpa = testing.allocator;
/// The server named in the environment, or a skip when there is none.fn server() ?[]const u8 { return testing.environ.getPosix("PAPERLESS_URL");}
fn connect() !paperless.Client { const url = server() orelse return error.SkipZigTest; return .init(testing.io, gpa, .{ .base_url = url, .auth = .{ .token = testing.environ.getPosix("PAPERLESS_TOKEN") orelse return error.SkipZigTest }, });}
/// Eight hex digits, so that names and file contents differ from run to run/// -- Paperless refuses a file whose checksum it already has.fn suffix() [8]u8 { var random: [4]u8 = undefined; testing.io.random(&random); var out: [8]u8 = undefined; _ = std.fmt.bufPrint(&out, "{x}", .{&random}) catch unreachable; return out;}
fn reportLastError(client: *const paperless.Client) void { if (client.last_error) |e| std.debug.print("server said {d}: {s}\n", .{ @backingInt(e.status), e.body });}
test "the server describes itself" { var client = try connect(); defer client.deinit(); errdefer reportLastError(&client);
const stats = try client.statistics(); defer stats.deinit();
const st = try client.status(); defer st.deinit(); try testing.expect(st.value.pngx_version.len > 0); try testing.expectEqualStrings("OK", st.value.database.?.status);
const remote = try client.remoteVersion(); defer remote.deinit();}
test "a document's whole life: upload, read, change, find, download, delete, restore" { var client = try connect(); defer client.deinit(); errdefer reportLastError(&client);
const tag_suffix = suffix(); var name_buffer: [64]u8 = undefined;
// The objects it is filed under. const tag = try client.tags().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "zig-paperless {s}", .{&tag_suffix}), .color = "#a6cee3", .matching_algorithm = .none, }); defer tag.deinit(); defer client.tags().delete(tag.value.id) catch {}; try testing.expectEqual(paperless.models.MatchingAlgorithm.none, tag.value.matching_algorithm);
const second_tag = try client.tags().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "zig-paperless second {s}", .{&tag_suffix}), .parent = .{ .set = tag.value.id }, }); defer second_tag.deinit(); defer client.tags().delete(second_tag.value.id) catch {}; try testing.expectEqual(tag.value.id, second_tag.value.parent.?);
const correspondent = try client.correspondents().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "ACME {s}", .{&tag_suffix}), }); defer correspondent.deinit(); defer client.correspondents().delete(correspondent.value.id) catch {};
const document_type = try client.documentTypes().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "Invoice {s}", .{&tag_suffix}), }); defer document_type.deinit(); defer client.documentTypes().delete(document_type.value.id) catch {};
const amount = try client.customFields().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "Amount {s}", .{&tag_suffix}), .data_type = .monetary, }); defer amount.deinit(); defer client.customFields().delete(amount.value.id) catch {}; try testing.expectEqual(paperless.models.CustomFieldType.monetary, amount.value.data_type);
// findByName ignores case. { var upper: [64]u8 = undefined; const found = (try client.tags().findByName(std.ascii.upperString(&upper, tag.value.name))).?; defer found.deinit(); try testing.expectEqual(tag.value.id, found.value.id); }
// The upload, and the task it becomes. var content_buffer: [128]u8 = undefined; const content = try std.fmt.bufPrint(&content_buffer, "Invoice {s} from ACME\nTotal due: 100 dollars\nzebrafish{s}\n", .{ &tag_suffix, &tag_suffix }); var filename_buffer: [64]u8 = undefined; const filename = try std.fmt.bufPrint(&filename_buffer, "facture café {s}.txt", .{&tag_suffix});
const task_id = try client.documents().upload(.{ .filename = filename, .content = .{ .bytes = content }, .title = "Uploaded by zig-paperless", .created = "2024-03-15", .tags = &.{tag.value.id}, .correspondent = correspondent.value.id, .document_type = document_type.value.id, .custom_fields = &.{.{ .field = amount.value.id, .value = .{ .string = "EUR12.50" } }}, }); const task = try client.tasks().wait(&task_id, .{ .interval = .fromMilliseconds(250), .timeout = .fromSeconds(120) }); defer task.deinit(); if (task.value.status != .success) { std.debug.print("upload failed: {s}\n", .{task.value.errorMessage() orelse "(no message)"}); return error.UploadFailed; } try testing.expectEqual(paperless.models.TaskType.consume_file, task.value.task_type); try testing.expectEqual(paperless.models.TaskTrigger.api_upload, task.value.trigger_source); const id = task.value.documentId().?; var deleted = false; defer if (!deleted) { client.documents().delete(id) catch {}; client.emptyTrash(&.{id}) catch {}; };
// What it became. { const doc = try client.documents().get(id, .{}); defer doc.deinit(); try testing.expectEqualStrings("Uploaded by zig-paperless", doc.value.title); try testing.expectEqualStrings(content, doc.value.content); try testing.expectEqualStrings("2024-03-15", doc.value.created.?); try testing.expectEqualStrings(filename, doc.value.original_file_name.?); try testing.expectEqualSlices(paperless.Id, &.{tag.value.id}, doc.value.tags); try testing.expectEqual(correspondent.value.id, doc.value.correspondent.?); try testing.expectEqual(document_type.value.id, doc.value.document_type.?); try testing.expectEqual(1, doc.value.custom_fields.len); try testing.expectEqual(amount.value.id, doc.value.custom_fields[0].field); try testing.expectEqualStrings("EUR12.50", doc.value.custom_fields[0].value.string); try testing.expectEqualStrings("text/plain", doc.value.mime_type); try testing.expect(doc.value.user_can_change.?); } { const doc = try client.documents().get(id, .{ .full_perms = true, .fields = "id,title,permissions" }); defer doc.deinit(); try testing.expect(doc.value.permissions != null); try testing.expectEqual(null, doc.value.user_can_change); try testing.expectEqualStrings("", doc.value.content); }
// Changing it: a field set, a field cleared, the tags replaced, a // custom field taken off by leaving it out. { const doc = try client.documents().update(id, .{ .title = "Renamed by zig-paperless", .correspondent = .clear, .tags = &.{ tag.value.id, second_tag.value.id }, .custom_fields = &.{}, }); defer doc.deinit(); try testing.expectEqualStrings("Renamed by zig-paperless", doc.value.title); try testing.expectEqual(null, doc.value.correspondent); try testing.expectEqual(document_type.value.id, doc.value.document_type.?); try testing.expectEqual(2, doc.value.tags.len); try testing.expectEqual(0, doc.value.custom_fields.len); }
// Finding it. { const page = try client.documents().list(.{ .tags__id__all = &.{ tag.value.id, second_tag.value.id }, .title__icontains = "renamed BY zig", }); defer page.deinit(); try testing.expectEqual(1, page.value.count); try testing.expectEqual(id, page.value.results[0].id); } { // The full-text index, which is written as the document is consumed. var term: [32]u8 = undefined; const page = try client.documents().list(.{ .query = try std.fmt.bufPrint(&term, "zebrafish{s}", .{&tag_suffix}) }); defer page.deinit(); try testing.expectEqual(1, page.value.count); try testing.expect(page.value.results[0].__search_hit__ != null); } { const search = try client.search("Renamed by zig-paperless", .{ .db_only = true }); defer search.deinit(); try testing.expect(search.value.documents.len >= 1); }
// Notes. { const added = try client.documents().addNote(id, "a note from zig-paperless"); defer added.deinit(); try testing.expectEqual(1, added.value.len); try testing.expectEqualStrings("a note from zig-paperless", added.value[0].note); const left = try client.documents().deleteNote(id, added.value[0].id); defer left.deinit(); try testing.expectEqual(0, left.value.len); }
// What is known about its file. { const meta = try client.documents().metadata(id); defer meta.deinit(); try testing.expectEqual(content.len, meta.value.original_size); try testing.expectEqualStrings("text/plain", meta.value.original_mime_type); const s = try client.documents().suggestions(id); defer s.deinit(); }
// The file itself, and its name, which is not ASCII. { var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); var info = try client.documents().download(id, &out.writer, .{ .file = .original }); defer info.deinit(); try testing.expectEqualStrings(content, out.written()); try testing.expect(std.mem.startsWith(u8, info.content_type.?, "text/plain")); try testing.expect(std.mem.endsWith(u8, info.filename.?, ".txt")); try testing.expect(std.unicode.utf8ValidateSlice(info.filename.?)); } { var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); var info = try client.documents().download(id, &out.writer, .{ .file = .thumbnail }); defer info.deinit(); try testing.expect(out.written().len > 0); try testing.expectEqualStrings("image/webp", info.content_type.?); }
// A share link to it. { const link = try client.shareLinks().create(.{ .document = id, .file_version = .original }); defer link.deinit(); try testing.expect(link.value.slug.len > 0); const links = try client.documents().shareLinks(id); defer links.deinit(); try testing.expectEqual(1, links.value.len); try client.shareLinks().delete(link.value.id); }
// Many documents at once -- one, here -- which happens in the // background, so it is waited for. try client.documents().bulkEdit(&.{id}, .{ .modify_tags = .{ .add = &.{}, .remove = &.{second_tag.value.id} } }); { var attempts: u32 = 0; while (true) : (attempts += 1) { const doc = try client.documents().get(id, .{ .fields = "id,tags" }); defer doc.deinit(); if (doc.value.tags.len == 1) break; if (attempts > 100) return error.BulkEditNeverApplied; try testing.io.sleep(.fromMilliseconds(100), .awake); } }
// The same file again. By default Paperless 3 consumes it and says which // document it duplicates; with PAPERLESS_CONSUMER_DELETE_DUPLICATES it // refuses it, as a failure that carries no message. The virtual machine // test sets that, and a server left at its defaults does not, so between // them both are covered. { const again = try client.documents().upload(.{ .filename = "again.txt", .content = .{ .bytes = content } }); const finished = try client.tasks().wait(&again, .{ .interval = .fromMilliseconds(250), .timeout = .fromSeconds(120) }); defer finished.deinit(); switch (finished.value.outcome()) { .duplicate => |d| { try testing.expectEqual(paperless.models.TaskStatus.failure, finished.value.status); try testing.expectEqual(id, d.of); try testing.expect(!d.in_trash); try testing.expectEqual(null, finished.value.documentId()); try testing.expectEqual(null, finished.value.errorMessage()); }, .created => |duplicate_id| { defer { client.documents().delete(duplicate_id) catch {}; client.emptyTrash(&.{duplicate_id}) catch {}; } const duplicate = try client.documents().get(duplicate_id, .{ .fields = "id,duplicate_documents" }); defer duplicate.deinit(); try testing.expectEqual(1, duplicate.value.duplicate_documents.len); try testing.expectEqual(id, duplicate.value.duplicate_documents[0].id); }, else => |o| { std.debug.print("a duplicate upload ended as: {f}\n", .{o}); return error.UnexpectedOutcome; }, } try client.tasks().acknowledge(&.{finished.value.id}); }
// To the trash, back, and out for good. try client.documents().delete(id); { const in_trash = try client.trash(.{}); defer in_trash.deinit(); var found = false; for (in_trash.value.results) |d| found = found or d.id == id; try testing.expect(found); } try client.restoreFromTrash(&.{id}); { const doc = try client.documents().get(id, .{ .fields = "id,title" }); defer doc.deinit(); } try client.documents().delete(id); try client.emptyTrash(&.{id}); deleted = true; try testing.expectError(error.NotFound, client.documents().get(id, .{})); try testing.expectEqual(.not_found, client.last_error.?.status);}
test "a token that is wrong is Unauthorized" { var client: paperless.Client = try .init(testing.io, gpa, .{ .base_url = server() orelse return error.SkipZigTest, .auth = .{ .token = "0000000000000000000000000000000000000000" }, }); defer client.deinit(); try testing.expectError(error.Unauthorized, client.statistics());}
test "every page of a list, a page at a time" { var client = try connect(); defer client.deinit(); errdefer reportLastError(&client);
const s = suffix(); var made: [5]paperless.Id = undefined; var name_buffer: [64]u8 = undefined; for (&made, 0..) |*id, i| { const tag = try client.tags().create(.{ .name = try std.fmt.bufPrint(&name_buffer, "zig-paperless pager {s} {d}", .{ &s, i }), }); defer tag.deinit(); id.* = tag.value.id; } defer for (made) |id| client.tags().delete(id) catch {};
var filter_buffer: [64]u8 = undefined; var pager = client.tags().pages(.{ .page_size = 2, .name__istartswith = try std.fmt.bufPrint(&filter_buffer, "zig-paperless pager {s}", .{&s}), .ordering = "name", }); var seen: usize = 0; var page_count: usize = 0; while (try pager.next()) |page| { defer page.deinit(); page_count += 1; for (page.value.results) |tag| { try testing.expectEqual(made[seen], tag.id); seen += 1; } } try testing.expectEqual(5, seen); try testing.expectEqual(3, page_count);}