TWO bugs, one of which was hiding the other.
1. "No signed copy on file" for a note that had just been signed,
scanned and uploaded. The upload was never the problem — the READ
path was. `delivery_notes.signed_copy` is a PB file field with
`maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
but PocketBase RETURNS A BARE STRING when exactly one file is
attached. Verified on the live record after a real upload:
typeof : string
Array.isArray : false
value : "image_ar52u2hjfq.jpg"
value[0] : "i" <-- indexing yields a CHARACTER
Every read site assumed `string[]`, and not one of them threw:
- signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
for notes that demonstrably had a copy — the reported error
- delivery-note detail tooltip: `signed_copy[0]` rendered the
first LETTER of the filename
- delivery-notes-service: treated every signed copy as absent, so
the quote went out with the UNSIGNED pdf attached
- deliveries-service: reported an empty filename
- deliveries page: `.join(", ")` on a string — this one THREW,
on a page people open daily
Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
and routes all of them through it. The type was the root of the
wrong assumptions, so it is now `string | string[]` with a comment
pointing at the helper.
2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
rasterising the real PDF at 200 dpi and decoding: it now reads at
1.24 px/module, where the old size needed ~2.5 — roughly double the
distance and angle tolerance.
It could not simply go at top:40: the logo is an absolute 160x100pt
block in the same corner, so the QR landed on top of it. That is
not only ugly, it breaks the QR's quiet zone — logo ink inside the
light margin makes the code refuse to decode. The logo now steps
left (right: 112 -> 106, width 160 -> 132) to give the QR the true
corner, and the company name is width-bounded ONLY when a logo is
present. The Westscott letterhead has no matching .svg in
public/letterhead/, so in practice the name still gets the full
width and stays on one line.
Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { firstFileName, fileNames, hasFile } from "./file-fields";
|
|
|
|
/**
|
|
* Regression guard for the "No signed copy on file" bug.
|
|
*
|
|
* `delivery_notes.signed_copy` is a PB file field with `maxSelect: 1`.
|
|
* We WRITE it as an array (`signed_copy: [file]`), but PocketBase
|
|
* RETURNS a bare STRING when exactly one file is attached. Verified
|
|
* against the live record on DN26100403 after a real upload:
|
|
*
|
|
* typeof : string
|
|
* Array.isArray : false
|
|
* value : "image_ar52u2hjfq.jpg"
|
|
* value[0] : "i"
|
|
*
|
|
* Every read site that assumed `string[]` therefore failed, and none
|
|
* of them threw: `Array.isArray(x) ? x[0] : null` returns null, so the
|
|
* app reported "No signed copy on file" for a note that had just been
|
|
* signed, scanned and uploaded. `x[0]` in a tooltip returned the first
|
|
* LETTER of the filename. One site called `.join(", ")` on the string,
|
|
* which does throw.
|
|
*
|
|
* These tests pin the real shapes PB returns, so the string case can
|
|
* never be quietly dropped again.
|
|
*/
|
|
|
|
describe("firstFileName", () => {
|
|
it("reads a bare string — the shape PB actually returns for one file", () => {
|
|
expect(firstFileName("image_ar52u2hjfq.jpg")).toBe("image_ar52u2hjfq.jpg");
|
|
});
|
|
|
|
it("reads an array, for the shape we WRITE or for several files", () => {
|
|
expect(firstFileName(["a.jpg"])).toBe("a.jpg");
|
|
expect(firstFileName(["a.jpg", "b.jpg"])).toBe("a.jpg");
|
|
});
|
|
|
|
it("skips empty entries rather than returning ''", () => {
|
|
expect(firstFileName(["", "b.jpg"])).toBe("b.jpg");
|
|
expect(firstFileName("")).toBeNull();
|
|
});
|
|
|
|
it("is null for nothing attached", () => {
|
|
expect(firstFileName(null)).toBeNull();
|
|
expect(firstFileName(undefined)).toBeNull();
|
|
expect(firstFileName([])).toBeNull();
|
|
});
|
|
|
|
it("does NOT index into the string — the bug this replaces", () => {
|
|
// `"image_x.jpg"[0]` is "i". Any read site doing that was showing
|
|
// the first character of the filename as though it were the file.
|
|
const value: string = "image_ar52u2hjfq.jpg";
|
|
expect(value[0]).toBe("i");
|
|
expect(firstFileName(value)).toBe("image_ar52u2hjfq.jpg");
|
|
});
|
|
});
|
|
|
|
describe("fileNames", () => {
|
|
it("normalises both shapes to an array", () => {
|
|
expect(fileNames("a.jpg")).toEqual(["a.jpg"]);
|
|
expect(fileNames(["a.jpg", "b.jpg"])).toEqual(["a.jpg", "b.jpg"]);
|
|
expect(fileNames(null)).toEqual([]);
|
|
expect(fileNames([])).toEqual([]);
|
|
});
|
|
|
|
it("would have thrown on the string form before — .join on a string", () => {
|
|
// This is the deliveries page crash: `dn.signed_copy.join(", ")`.
|
|
const value = "image_ar52u2hjfq.jpg" as unknown as string[];
|
|
expect(() => value.join(", ")).toThrow();
|
|
expect(fileNames("image_ar52u2hjfq.jpg").join(", ")).toBe("image_ar52u2hjfq.jpg");
|
|
});
|
|
});
|
|
|
|
describe("hasFile", () => {
|
|
it("is truthy for either shape", () => {
|
|
expect(hasFile("a.jpg")).toBe(true);
|
|
expect(hasFile(["a.jpg"])).toBe(true);
|
|
});
|
|
|
|
it("is falsy when empty", () => {
|
|
expect(hasFile(null)).toBe(false);
|
|
expect(hasFile("")).toBe(false);
|
|
expect(hasFile([])).toBe(false);
|
|
expect(hasFile(undefined)).toBe(false);
|
|
});
|
|
});
|