// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The client against a fake Paperless on a loopback socket. //! //! The fake is zig-http's own server with a handler that answers the way //! Paperless-ngx 3.2 does -- the JSON in these responses was copied from a //! real one -- and that writes down what each request looked like when it //! arrived. So these check both halves: that a request goes out with the //! path, query, headers and body Paperless expects, and that what Paperless //! sends back is read correctly. Whether the fake is faithful is what //! `tests/live.zig` checks, against the real thing. const std = @import("std"); const Io = std.Io; const testing = std.testing; const http = @import("http"); const http_io = @import("http_io"); const paperless = @import("paperless"); const Request = http.server.Request; const Response = http.server.Response; const gpa = testing.allocator; /// What the last request looked like, as the server saw it. Tests run one at /// a time and each reads this only after its request has been answered. const Seen = struct { method: [8]u8 = undefined, method_len: usize = 0, target: [512]u8 = undefined, target_len: usize = 0, authorization: [128]u8 = undefined, authorization_len: usize = 0, accept: [64]u8 = undefined, accept_len: usize = 0, content_type: [128]u8 = undefined, content_type_len: usize = 0, body: [8192]u8 = undefined, body_len: usize = 0, fn set(buffer: []u8, len: *usize, value: []const u8) void { const n = @min(buffer.len, value.len); @memcpy(buffer[0..n], value[0..n]); len.* = n; } fn methodName(s: *const Seen) []const u8 { return s.method[0..s.method_len]; } fn targetText(s: *const Seen) []const u8 { return s.target[0..s.target_len]; } fn bodyText(s: *const Seen) []const u8 { return s.body[0..s.body_len]; } }; var seen: Seen = .{}; /// How many times the task has been asked after, so that it can finish on /// the third. var task_polls: u32 = 0; const document_json = \\{"id":1,"correspondent":1,"document_type":null,"storage_path":null,"title":"Hello", \\"content":"Invoice 42 from ACME\nTotal due: 100 dollars\n","tags":[1],"created":"2026-10-05", \\"created_date":"2026-10-05","modified":"2026-10-05T20:14:48.386910Z","added":"2026-10-05T20:14:48.347739Z", \\"deleted_at":null,"archive_serial_number":null,"original_file_name":"invoice.txt","archived_file_name":null, \\"duplicate_documents":[],"owner":1,"user_can_change":true,"is_shared_by_requester":false, \\"notes":[{"id":1,"note":"first note","created":"2026-10-05T20:17:39.292056Z","user":{"id":1,"username":"admin","first_name":"","last_name":""}}], \\"custom_fields":[{"field":2,"value":"EUR12.50"}],"page_count":null,"mime_type":"text/plain","root_document":null, \\"versions":[{"id":1,"added":"2026-10-05T20:14:48.347739Z","version_label":null,"checksum":"f5b1","is_root":true}]} ; fn routes(req: *Request, res: *Response) anyerror!void { Seen.set(&seen.method, &seen.method_len, req.method.name); var target: Io.Writer = .fixed(&seen.target); target.writeAll(req.target.path) catch {}; if (req.target.query) |q| { target.writeByte('?') catch {}; target.writeAll(q) catch {}; } seen.target_len = target.end; Seen.set(&seen.authorization, &seen.authorization_len, req.header("authorization") orelse ""); Seen.set(&seen.accept, &seen.accept_len, req.header("accept") orelse ""); Seen.set(&seen.content_type, &seen.content_type_len, req.header("content-type") orelse ""); const body = try req.body.readAlloc(req.arena, 1 << 20); Seen.set(&seen.body, &seen.body_len, body); const path = req.target.path; const method = req.method.name; try res.setHeader("Content-Type", "application/json"); if (std.mem.eql(u8, path, "/api/documents/") and std.mem.eql(u8, method, "GET")) { // Three pages of one document each, by `page`. const q = req.target.query orelse ""; const page: u8 = if (std.mem.find(u8, q, "page=2") != null) 2 else if (std.mem.find(u8, q, "page=3") != null) 3 else 1; return res.sendPrint( \\{{"count":3,"next":{s},"previous":null,"all":[1,2,3],"results":[{{"id":{d},"title":"page {d}"}}]}} , .{ if (page < 3) "\"http://elsewhere.invalid/api/documents/?page=2\"" else "null", page, page }); } if (std.mem.eql(u8, path, "/api/documents/1/")) { if (std.mem.eql(u8, method, "DELETE")) return res.sendStatus(.no_content); return res.send(document_json); } if (std.mem.eql(u8, path, "/api/documents/404/")) { res.status = .not_found; return res.send("{\"detail\":\"No Document matches the given query.\"}"); } if (std.mem.eql(u8, path, "/api/documents/1/download/")) { try res.setHeader("Content-Type", "application/pdf"); try res.setHeader("Content-Disposition", "attachment; filename=\"caf.pdf\"; filename*=utf-8''caf%C3%A9.pdf"); return res.send("%PDF-1.7 pretend"); } if (std.mem.eql(u8, path, "/api/documents/post_document/")) { return res.send("\"737bdbcd-bd4b-42a2-9951-f0fcef1774c9\""); } if (std.mem.eql(u8, path, "/api/documents/bulk_edit/")) { return res.send("{\"result\":\"OK\"}"); } if (std.mem.eql(u8, path, "/api/tasks/")) { task_polls += 1; const status = if (task_polls < 3) "started" else "success"; return res.sendPrint( \\{{"count":1,"next":null,"previous":null,"results":[{{"id":2,"task_id":"737bdbcd-bd4b-42a2-9951-f0fcef1774c9", \\"task_type":"consume_file","trigger_source":"api_upload","status":"{s}","result_data":{s}, \\"related_document_ids":[],"acknowledged":false,"owner":1}}]}} , .{ status, if (task_polls < 3) "null" else "{\"document_id\":1}" }); } if (std.mem.eql(u8, path, "/api/tags/") and std.mem.eql(u8, method, "POST")) { res.status = .created; return res.send( \\{"id":5,"slug":"invoices","name":"invoices","color":"#ff0000","text_color":"#000000","match":"", \\"matching_algorithm":4,"is_insensitive":true,"is_inbox_tag":false,"owner":1,"user_can_change":true, \\"parent":null,"children":[]} ); } if (std.mem.eql(u8, path, "/api/tags/") and std.mem.eql(u8, method, "GET")) { return res.send( \\{"count":1,"next":null,"previous":null,"results":[{"id":5,"name":"invoices","document_count":3}],"display_count":1} ); } if (std.mem.eql(u8, path, "/api/correspondents/")) { res.status = .bad_request; return res.send("{\"name\":[\"This field is required.\"]}"); } if (std.mem.eql(u8, path, "/api/token/")) { return res.send("{\"token\":\"492ddbf785d06855752ae8bfa6ba67b40339a83d\"}"); } res.status = .not_found; return res.send("{\"detail\":\"Not found.\"}"); } const Fixture = struct { listener: http_io.tcp.Listener = undefined, server: http.server.Server = undefined, tasks: Io.Group = .init, url_buffer: [64]u8 = undefined, fn start(f: *Fixture) !void { const io = testing.io; f.* = .{}; seen = .{}; task_polls = 0; f.listener = try .listen(io, .{ .ip4 = .loopback(0) }, .{}); errdefer f.listener.deinit(io); f.server = .init(io, gpa, f.listener.listener(), .initStateless(routes), .{ .http1 = .{ .send_date = false, .server_name = null }, }); // Without a second task the accept and the connect deadlock. f.tasks.concurrent(io, run, .{&f.server}) catch return error.SkipZigTest; } fn run(server: *http.server.Server) void { server.serve() catch {}; } fn stop(f: *Fixture) void { f.server.shutdown(); f.tasks.await(testing.io) catch {}; f.listener.deinit(testing.io); } /// With a trailing slash, which `Client.init` takes off. fn baseUrl(f: *Fixture) []const u8 { return std.fmt.bufPrint(&f.url_buffer, "http://127.0.0.1:{d}/", .{f.listener.localAddress().getPort()}) catch unreachable; } fn client(f: *Fixture) !paperless.Client { return .init(testing.io, gpa, .{ .base_url = f.baseUrl(), .auth = .{ .token = "492ddbf785d06855752ae8bfa6ba67b40339a83d" }, }); } }; test "a list goes out with its filters, its token and its API version" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const page = try client.documents().list(.{ .query = "invoice tag:acme", .tags__id__all = &.{ 1, 4 }, .is_in_inbox = false, .ordering = "-created", .page_size = 1, }); defer page.deinit(); try testing.expectEqualStrings("GET", seen.methodName()); try testing.expectEqualStrings( "/api/documents/?page_size=1&ordering=-created&query=invoice%20tag%3Aacme&tags__id__all=1%2C4&is_in_inbox=false", seen.targetText(), ); try testing.expectEqualStrings("Token 492ddbf785d06855752ae8bfa6ba67b40339a83d", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings("application/json; version=10", seen.accept[0..seen.accept_len]); try testing.expectEqual(3, page.value.count); try testing.expectEqualSlices(paperless.Id, &.{ 1, 2, 3 }, page.value.all); try testing.expectEqualStrings("page 1", page.value.results[0].title); } // Django reads a query string as form data, where `+` is a space, so a // literal plus has to arrive as `%2B` -- which zig-http, since 0.3.0, sends // as it was written rather than re-encoding the query through zig-uri. test "a plus sign reaches the server as a plus" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const page = try client.documents().list(.{ .title__icontains = "a+b" }); defer page.deinit(); try testing.expectEqualStrings("/api/documents/?title__icontains=a%2Bb", seen.targetText()); } test "the pager counts pages rather than following a next link it cannot reach" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); var pager = client.documents().pages(.{ .page_size = 1 }); var titles: [3][]const u8 = undefined; var n: usize = 0; while (try pager.next()) |page| { defer page.deinit(); try testing.expect(n < titles.len); titles[n] = if (std.mem.eql(u8, page.value.results[0].title, "page 1")) "1" else if (std.mem.eql(u8, page.value.results[0].title, "page 2")) "2" else "3"; n += 1; } try testing.expectEqual(3, n); try testing.expectEqualStrings("123", &.{ titles[0][0], titles[1][0], titles[2][0] }); } test "a document reads in full, custom fields and notes included" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const doc = try client.documents().get(1, .{}); defer doc.deinit(); try testing.expectEqualStrings("/api/documents/1/", seen.targetText()); try testing.expectEqualStrings("Hello", doc.value.title); try testing.expectEqual(1, doc.value.correspondent.?); try testing.expectEqual(null, doc.value.document_type); try testing.expectEqualStrings("first note", doc.value.notes[0].note); try testing.expectEqualStrings("admin", doc.value.notes[0].user.?.username); try testing.expectEqual(2, doc.value.custom_fields[0].field); try testing.expectEqualStrings("EUR12.50", doc.value.custom_fields[0].value.string); try testing.expect(doc.value.versions[0].is_root); } test "an update sends only what it changes, and null for what it clears" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const doc = try client.documents().update(1, .{ .title = "Renamed", .correspondent = .clear, .tags = &.{ 1, 2 }, }); defer doc.deinit(); try testing.expectEqualStrings("PATCH", seen.methodName()); try testing.expectEqualStrings("application/json", seen.content_type[0..seen.content_type_len]); try testing.expectEqualStrings( \\{"title":"Renamed","correspondent":null,"tags":[1,2]} , seen.bodyText()); } test "a missing document is NotFound, with what the server said kept" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); try testing.expectError(error.NotFound, client.documents().get(404, .{})); try testing.expectEqual(.not_found, client.last_error.?.status); try testing.expectEqualStrings("{\"detail\":\"No Document matches the given query.\"}", client.last_error.?.body); // And a 400 explains itself field by field. try testing.expectError(error.BadRequest, client.correspondents().create(.{})); try testing.expectEqualStrings("{\"name\":[\"This field is required.\"]}", client.last_error.?.body); // The next request that succeeds forgets it. const page = try client.tags().list(.{}); defer page.deinit(); try testing.expectEqual(null, client.last_error); } test "delete is a DELETE and a 204 is success" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); try client.documents().delete(1); try testing.expectEqualStrings("DELETE", seen.methodName()); } test "an upload is a multipart form with the file last, and hands back a task" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const task_id = try client.documents().upload(.{ .filename = "invoice.pdf", .content = .{ .bytes = "%PDF-1.7 pretend" }, .title = "Invoice 42", .tags = &.{ 1, 3 }, .correspondent = 2, .custom_fields = &.{.{ .field = 2, .value = .{ .string = "EUR12.50" } }}, }); try testing.expectEqualStrings("737bdbcd-bd4b-42a2-9951-f0fcef1774c9", &task_id); try testing.expectEqualStrings("POST", seen.methodName()); try testing.expectEqualStrings("/api/documents/post_document/", seen.targetText()); const content_type = seen.content_type[0..seen.content_type_len]; try testing.expect(std.mem.startsWith(u8, content_type, "multipart/form-data; boundary=zig-paperless-")); const boundary = content_type["multipart/form-data; boundary=".len..]; const body = seen.bodyText(); try testing.expect(std.mem.find(u8, body, "name=\"title\"\r\n\r\nInvoice 42\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"tags\"\r\n\r\n1\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"tags\"\r\n\r\n3\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"correspondent\"\r\n\r\n2\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"custom_fields\"\r\n\r\n{\"2\":\"EUR12.50\"}\r\n") != null); try testing.expect(std.mem.find(u8, body, "name=\"document\"; filename=\"invoice.pdf\"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.7 pretend\r\n") != null); // The closing delimiter, and nothing after it. var closing: [128]u8 = undefined; try testing.expect(std.mem.endsWith(u8, body, try std.fmt.bufPrint(&closing, "\r\n--{s}--\r\n", .{boundary}))); } test "an upload read from a reader sends exactly its length" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); var digits: [3000]u8 = undefined; for (&digits, 0..) |*d, i| d.* = '0' + @as(u8, @intCast(i % 10)); var content: Io.Reader = .fixed(&digits); _ = try client.documents().upload(.{ .filename = "digits.txt", .content = .{ .reader = .{ .reader = &content, .length = 3000 } }, }); const body = seen.bodyText(); const at = std.mem.find(u8, body, "Content-Type: text/plain\r\n\r\n").? + "Content-Type: text/plain\r\n\r\n".len; try testing.expectEqualStrings(&digits, body[at..][0..digits.len]); try testing.expect(std.mem.startsWith(u8, body[at + digits.len ..], "\r\n--")); } test "waiting on a task asks until it is done and names the document" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const task = try client.tasks().wait("737bdbcd-bd4b-42a2-9951-f0fcef1774c9", .{ .interval = .fromMilliseconds(1) }); defer task.deinit(); try testing.expectEqual(3, task_polls); try testing.expectEqualStrings("/api/tasks/?task_id=737bdbcd-bd4b-42a2-9951-f0fcef1774c9", seen.targetText()); try testing.expectEqual(paperless.models.TaskStatus.success, task.value.status); try testing.expectEqual(1, task.value.documentId().?); } test "a download names its file from Content-Disposition, preferring filename*" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); var info = try client.documents().download(1, &out.writer, .{ .file = .original }); defer info.deinit(); try testing.expectEqualStrings("/api/documents/1/download/?original=true", seen.targetText()); try testing.expectEqualStrings("*/*", seen.accept[0..seen.accept_len]); try testing.expectEqualStrings("%PDF-1.7 pretend", out.written()); try testing.expectEqualStrings("application/pdf", info.content_type.?); try testing.expectEqualStrings("café.pdf", info.filename.?); } test "a failed download leaves the caller's writer untouched" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); try testing.expectError(error.NotFound, client.documents().download(77, &out.writer, .{})); try testing.expectEqualStrings("", out.written()); } test "bulk edit sends the method and its parameters" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); try client.documents().bulkEdit(&.{ 1, 2 }, .{ .add_tag = 9 }); try testing.expectEqualStrings( \\{"documents":[1,2],"method":"add_tag","parameters":{"tag":9}} , seen.bodyText()); } test "create sends a matching algorithm as a number, and findByName reads the first match" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client = try f.client(); defer client.deinit(); const tag = try client.tags().create(.{ .name = "invoices", .matching_algorithm = .regex, .match = "^INV" }); defer tag.deinit(); try testing.expectEqualStrings( \\{"name":"invoices","match":"^INV","matching_algorithm":4} , seen.bodyText()); try testing.expectEqual(paperless.models.MatchingAlgorithm.regex, tag.value.matching_algorithm); const found = (try client.tags().findByName("Invoices")).?; defer found.deinit(); try testing.expectEqualStrings("/api/tags/?page_size=1&name__iexact=Invoices", seen.targetText()); try testing.expectEqual(5, found.value.id); try testing.expectEqual(3, found.value.document_count); } test "basic credentials are sent as Basic" { var f: Fixture = undefined; try f.start(); defer f.stop(); var client: paperless.Client = try .init(testing.io, gpa, .{ .base_url = f.baseUrl(), .auth = .{ .basic = .{ .username = "admin", .password = "admin" } }, .api_version = 9, }); defer client.deinit(); const page = try client.tags().list(.{}); defer page.deinit(); try testing.expectEqualStrings("Basic YWRtaW46YWRtaW4=", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings("application/json; version=9", seen.accept[0..seen.accept_len]); } test "obtainToken trades a password for the token" { var f: Fixture = undefined; try f.start(); defer f.stop(); const token = try paperless.obtainToken(testing.io, gpa, f.baseUrl(), "admin", "pass word"); defer gpa.free(token); try testing.expectEqualStrings("492ddbf785d06855752ae8bfa6ba67b40339a83d", token); try testing.expectEqualStrings("", seen.authorization[0..seen.authorization_len]); try testing.expectEqualStrings( \\{"username":"admin","password":"pass word"} , seen.bodyText()); }