fix(qsl): copy a design's pictures, not just its row

copyDirContents wrote into a destination it never created, so the first
os.Create returned ENOENT. The caller was written as "ignore os.IsNotExist"
— meant for a design with no pictures at all — and that guard matched the
failed write exactly: nothing was copied, no error surfaced, and validation
two lines later reported "copied design is incomplete: hero photo file
img_… not found" with no way to tell why.

Three fixes, one cause:

  - copyDirContents creates its destination;
  - the caller stats the source folder instead of pattern-matching an error,
    so a genuine copy failure is reported and rolls back both the row and
    the folder;
  - DuplicateProfile had the same defect in its own form — an INSERT … SELECT
    that cloned the template rows and left their photos behind, giving the
    new profile designs with no thumbnail and nothing to print. It now copies
    row and folder together, per template, since each needs its id first.

A test reproduces the original failure: it fails with the exact ENOENT that
was being swallowed.
This commit is contained in:
2026-08-14 11:57:35 +02:00
parent 4e88bdfaa7
commit 05fc8ad80c
4 changed files with 138 additions and 11 deletions
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestCopyDirContentsCreatesDestination pins the bug that broke copying a QSL
// design between profiles.
//
// copyDirContents wrote straight into dst without creating it, so the first
// os.Create failed with ENOENT. The caller was written as "ignore
// os.IsNotExist", meaning "the source has no assets, that is fine" — and ENOENT
// from the failed write matched it exactly. Nothing was copied, no error was
// reported, and the operator saw "copied design is incomplete: hero photo file
// … not found" from the validation two lines later.
func TestCopyDirContentsCreatesDestination(t *testing.T) {
base := t.TempDir()
src := filepath.Join(base, "src")
if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "img_25c06bda.jpg"), []byte("hero"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "sub", "back.png"), []byte("back"), 0o644); err != nil {
t.Fatal(err)
}
// dst does not exist — the case of every first copy.
dst := filepath.Join(base, "dst")
if err := copyDirContents(src, dst); err != nil {
t.Fatalf("copyDirContents: %v", err)
}
for name, want := range map[string]string{
"img_25c06bda.jpg": "hero",
"sub/back.png": "back",
} {
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name)))
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
}
// A missing SOURCE is still an error from copyDirContents itself — it is the
// caller that decides whether a design with no pictures is acceptable, and it
// can only decide that if this does not quietly succeed.
func TestCopyDirContentsMissingSource(t *testing.T) {
base := t.TempDir()
if err := copyDirContents(filepath.Join(base, "nope"), filepath.Join(base, "dst")); err == nil {
t.Fatal("copying a missing source returned no error")
} else if !os.IsNotExist(err) {
t.Fatalf("want a not-exist error, got %v", err)
}
}