| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/base64" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | func TestSaveExportFileWritesTextAndBinaryPayloads(t *testing.T) { |
| 16 | t.Parallel() |
| 17 | app := &App{} |
| 18 | dir := t.TempDir() |
| 19 | |
| 20 | textPath := filepath.Join(dir, "session.md") |
| 21 | if err := app.SaveExportFile(textPath, "# 会话\n", false); err != nil { |
| 22 | t.Fatalf("save text export: %v", err) |
| 23 | } |
| 24 | text, err := os.ReadFile(textPath) |
| 25 | if err != nil { |
| 26 | t.Fatalf("read text export: %v", err) |
| 27 | } |
| 28 | if got, want := string(text), "# 会话\n"; got != want { |
| 29 | t.Fatalf("text export = %q, want %q", got, want) |
| 30 | } |
| 31 | |
| 32 | binaryPath := filepath.Join(dir, "session.png") |
| 33 | binary := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0xff} |
| 34 | if err := app.SaveExportFile(binaryPath, base64.StdEncoding.EncodeToString(binary), true); err != nil { |
| 35 | t.Fatalf("save binary export: %v", err) |
| 36 | } |
| 37 | written, err := os.ReadFile(binaryPath) |
| 38 | if err != nil { |
| 39 | t.Fatalf("read binary export: %v", err) |
| 40 | } |
| 41 | if string(written) != string(binary) { |
| 42 | t.Fatalf("binary export = %v, want %v", written, binary) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestSaveExportFileRejectsInvalidBase64(t *testing.T) { |
| 47 | t.Parallel() |
| 48 | path := filepath.Join(t.TempDir(), "broken.pdf") |
| 49 | err := (&App{}).SaveExportFile(path, "not base64!", true) |
| 50 | if err == nil { |
| 51 | t.Fatal("expected invalid base64 error") |
| 52 | } |
| 53 | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { |
| 54 | t.Fatalf("invalid payload should not create a file, stat error = %v", statErr) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func TestExportErrorsDoNotExposeSelectedDirectory(t *testing.T) { |
| 59 | t.Parallel() |
| 60 | dir := t.TempDir() |
| 61 | missingDir := filepath.Join(dir, "private-export-directory") |
| 62 | payload := base64.StdEncoding.EncodeToString([]byte("image")) |
| 63 | tests := []struct { |
| 64 | name string |
| 65 | path string |
| 66 | run func(string) error |
| 67 | }{ |
| 68 | { |
| 69 | name: "single file", |
| 70 | path: filepath.Join(missingDir, "session.pdf"), |
| 71 | run: func(path string) error { |
| 72 | return (&App{}).SaveExportFile(path, payload, true) |
| 73 | }, |
| 74 | }, |
| 75 | { |
| 76 | name: "multipart image", |
| 77 | path: filepath.Join(missingDir, "session.png"), |
| 78 | run: func(path string) error { |
| 79 | return (&App{}).SaveExportImageFiles(path, []string{payload, payload}) |
| 80 | }, |
| 81 | }, |
| 82 | } |
| 83 | for _, test := range tests { |
| 84 | test := test |
| 85 | t.Run(test.name, func(t *testing.T) { |
| 86 | err := test.run(test.path) |
| 87 | if err == nil { |
| 88 | t.Fatal("expected missing export directory to fail") |
| 89 | } |
| 90 | if strings.Contains(err.Error(), dir) { |
| 91 | t.Fatalf("export error exposed selected directory: %q", err) |
| 92 | } |
| 93 | if !strings.Contains(err.Error(), "session") { |
| 94 | t.Fatalf("export error should retain a safe file name: %q", err) |
| 95 | } |
| 96 | }) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestSaveExportImageFilesWritesNumberedParts(t *testing.T) { |
| 101 | t.Parallel() |
| 102 | dir := t.TempDir() |
| 103 | path := filepath.Join(dir, "session.archive.png") |
| 104 | payloads := [][]byte{{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}} |
| 105 | encoded := make([]string, len(payloads)) |
| 106 | for i, payload := range payloads { |
| 107 | encoded[i] = base64.StdEncoding.EncodeToString(payload) |
| 108 | } |
| 109 | |
| 110 | if err := (&App{}).SaveExportImageFiles(path, encoded); err != nil { |
| 111 | t.Fatalf("save image parts: %v", err) |
| 112 | } |
| 113 | for i, want := range payloads { |
| 114 | partPath := filepath.Join(dir, fmt.Sprintf("session.archive-%d-of-3.png", i+1)) |
| 115 | got, err := os.ReadFile(partPath) |
| 116 | if err != nil { |
| 117 | t.Fatalf("read image part %d: %v", i+1, err) |
| 118 | } |
| 119 | if !bytes.Equal(got, want) { |
| 120 | t.Fatalf("image part %d = %v, want %v", i+1, got, want) |
| 121 | } |
| 122 | } |
| 123 | if _, err := os.Stat(path); !os.IsNotExist(err) { |
| 124 | t.Fatalf("multi-part export should not write the selected base path, stat error = %v", err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestSaveExportImageFilesPreservesSelectedPath(t *testing.T) { |
| 129 | t.Parallel() |
| 130 | if runtime.GOOS == "windows" { |
| 131 | t.Skip("Windows normalizes trailing spaces in file names") |
| 132 | } |
| 133 | dir := t.TempDir() |
| 134 | selectedPath := filepath.Join(dir, "session.png ") |
| 135 | neighborPath := filepath.Join(dir, "session.png") |
| 136 | if err := os.WriteFile(neighborPath, []byte("keep me"), 0o644); err != nil { |
| 137 | t.Fatalf("seed neighboring file: %v", err) |
| 138 | } |
| 139 | payload := base64.StdEncoding.EncodeToString([]byte("new image")) |
| 140 | |
| 141 | if err := (&App{}).SaveExportImageFiles(selectedPath, []string{payload}); err != nil { |
| 142 | t.Fatalf("save exact selected path: %v", err) |
| 143 | } |
| 144 | if got, err := os.ReadFile(selectedPath); err != nil || string(got) != "new image" { |
| 145 | t.Fatalf("selected path data = %q, err = %v", got, err) |
| 146 | } |
| 147 | if got, err := os.ReadFile(neighborPath); err != nil || string(got) != "keep me" { |
| 148 | t.Fatalf("neighboring file changed: data=%q err=%v", got, err) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestSaveExportImageFilesMatchesNormalExportPermissions(t *testing.T) { |
| 153 | t.Parallel() |
| 154 | dir := t.TempDir() |
| 155 | referencePath := filepath.Join(dir, "reference.png") |
| 156 | if err := (&App{}).SaveExportFile(referencePath, base64.StdEncoding.EncodeToString([]byte("reference")), true); err != nil { |
| 157 | t.Fatalf("save reference export: %v", err) |
| 158 | } |
| 159 | payload := base64.StdEncoding.EncodeToString([]byte("image")) |
| 160 | if err := (&App{}).SaveExportImageFiles(filepath.Join(dir, "session.png"), []string{payload, payload}); err != nil { |
| 161 | t.Fatalf("save multipart export: %v", err) |
| 162 | } |
| 163 | |
| 164 | referenceInfo, err := os.Stat(referencePath) |
| 165 | if err != nil { |
| 166 | t.Fatalf("stat reference export: %v", err) |
| 167 | } |
| 168 | partInfo, err := os.Stat(filepath.Join(dir, "session-1-of-2.png")) |
| 169 | if err != nil { |
| 170 | t.Fatalf("stat multipart export: %v", err) |
| 171 | } |
| 172 | if got, want := partInfo.Mode().Perm(), referenceInfo.Mode().Perm(); got != want { |
| 173 | t.Fatalf("multipart permissions = %v, want normal export permissions %v", got, want) |
| 174 | } |
| 175 | if matches, err := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); err != nil || len(matches) != 0 { |
| 176 | t.Fatalf("staged files remain after successful export: matches=%v err=%v", matches, err) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestSaveExportImageFilesRejectsCollisionWithoutPartialOutput(t *testing.T) { |
| 181 | t.Parallel() |
| 182 | dir := t.TempDir() |
| 183 | path := filepath.Join(dir, "session.png") |
| 184 | collisionPath := filepath.Join(dir, "session-2-of-3.png") |
| 185 | if err := os.WriteFile(collisionPath, []byte("keep me"), 0o644); err != nil { |
| 186 | t.Fatalf("seed collision: %v", err) |
| 187 | } |
| 188 | payload := base64.StdEncoding.EncodeToString([]byte("new image")) |
| 189 | |
| 190 | err := (&App{}).SaveExportImageFiles(path, []string{payload, payload, payload}) |
| 191 | if err == nil { |
| 192 | t.Fatal("expected existing numbered export to reject the batch") |
| 193 | } |
| 194 | if got, readErr := os.ReadFile(collisionPath); readErr != nil || string(got) != "keep me" { |
| 195 | t.Fatalf("existing image part changed: data=%q err=%v", got, readErr) |
| 196 | } |
| 197 | for _, name := range []string{"session-1-of-3.png", "session-3-of-3.png"} { |
| 198 | if _, statErr := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(statErr) { |
| 199 | t.Fatalf("collision should leave no partial output %s, stat error = %v", name, statErr) |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestSaveExportImageFilesDecodesAllPartsBeforeWriting(t *testing.T) { |
| 205 | t.Parallel() |
| 206 | dir := t.TempDir() |
| 207 | path := filepath.Join(dir, "session.png") |
| 208 | valid := base64.StdEncoding.EncodeToString([]byte("image")) |
| 209 | |
| 210 | err := (&App{}).SaveExportImageFiles(path, []string{valid, "not base64!", valid}) |
| 211 | if err == nil { |
| 212 | t.Fatal("expected invalid image payload to reject the batch") |
| 213 | } |
| 214 | for i := 1; i <= 3; i++ { |
| 215 | partPath := filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i)) |
| 216 | if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) { |
| 217 | t.Fatalf("invalid payload should leave no image part %d, stat error = %v", i, statErr) |
| 218 | } |
| 219 | } |
| 220 | if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 { |
| 221 | t.Fatalf("invalid payload left staged files: matches=%v err=%v", matches, globErr) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestSaveExclusiveExportFilesRollsBackCommittedTargets(t *testing.T) { |
| 226 | t.Parallel() |
| 227 | target := filepath.Join(t.TempDir(), "duplicate.png") |
| 228 | |
| 229 | err := saveExclusiveExportFiles( |
| 230 | []string{target, target}, |
| 231 | [][]byte{[]byte("first"), []byte("second")}, |
| 232 | ) |
| 233 | if err == nil { |
| 234 | t.Fatal("expected duplicate exclusive target to fail") |
| 235 | } |
| 236 | if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { |
| 237 | t.Fatalf("failed batch should roll back its committed target, stat error = %v", statErr) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestRollbackDoesNotRemoveReplacedExportTarget(t *testing.T) { |
| 242 | t.Parallel() |
| 243 | dir := t.TempDir() |
| 244 | tempPath := filepath.Join(dir, ".staged.png") |
| 245 | targetPath := filepath.Join(dir, "session.png") |
| 246 | if err := os.WriteFile(tempPath, []byte("staged"), 0o644); err != nil { |
| 247 | t.Fatalf("write staged file: %v", err) |
| 248 | } |
| 249 | created, err := commitStagedExportFile(tempPath, targetPath) |
| 250 | if err != nil { |
| 251 | t.Fatalf("commit staged file: %v", err) |
| 252 | } |
| 253 | tempInfo, err := os.Lstat(tempPath) |
| 254 | if err != nil { |
| 255 | t.Fatalf("stat staged file: %v", err) |
| 256 | } |
| 257 | if !os.SameFile(created, tempInfo) { |
| 258 | t.Fatal("commit must return the staged inode identity") |
| 259 | } |
| 260 | if err := os.Remove(targetPath); err != nil { |
| 261 | t.Fatalf("replace committed target: %v", err) |
| 262 | } |
| 263 | if err := os.WriteFile(targetPath, []byte("replacement"), 0o644); err != nil { |
| 264 | t.Fatalf("write replacement target: %v", err) |
| 265 | } |
| 266 | |
| 267 | rollbackCommittedExportFiles([]committedExportFile{{path: targetPath, info: created}}) |
| 268 | if got, err := os.ReadFile(targetPath); err != nil || string(got) != "replacement" { |
| 269 | t.Fatalf("rollback removed replacement: data=%q err=%v", got, err) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestConcurrentMultipartExportsHaveSingleCompleteWinner(t *testing.T) { |
| 274 | t.Parallel() |
| 275 | dir := t.TempDir() |
| 276 | path := filepath.Join(dir, "session.png") |
| 277 | encode := func(values ...string) []string { |
| 278 | encoded := make([]string, len(values)) |
| 279 | for i, value := range values { |
| 280 | encoded[i] = base64.StdEncoding.EncodeToString([]byte(value)) |
| 281 | } |
| 282 | return encoded |
| 283 | } |
| 284 | batches := [][]string{ |
| 285 | encode("a-1", "a-2", "a-3"), |
| 286 | encode("b-1", "b-2", "b-3"), |
| 287 | } |
| 288 | start := make(chan struct{}) |
| 289 | errs := make(chan error, len(batches)) |
| 290 | var ready sync.WaitGroup |
| 291 | ready.Add(len(batches)) |
| 292 | for _, batch := range batches { |
| 293 | batch := batch |
| 294 | go func() { |
| 295 | ready.Done() |
| 296 | <-start |
| 297 | errs <- (&App{}).SaveExportImageFiles(path, batch) |
| 298 | }() |
| 299 | } |
| 300 | ready.Wait() |
| 301 | close(start) |
| 302 | |
| 303 | successes := 0 |
| 304 | for range batches { |
| 305 | if err := <-errs; err == nil { |
| 306 | successes++ |
| 307 | } |
| 308 | } |
| 309 | if successes != 1 { |
| 310 | t.Fatalf("successful concurrent exports = %d, want exactly one", successes) |
| 311 | } |
| 312 | first, err := os.ReadFile(filepath.Join(dir, "session-1-of-3.png")) |
| 313 | if err != nil { |
| 314 | t.Fatalf("read winning first part: %v", err) |
| 315 | } |
| 316 | winner := string(first[:1]) |
| 317 | for i := 1; i <= 3; i++ { |
| 318 | got, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i))) |
| 319 | if err != nil { |
| 320 | t.Fatalf("read winning part %d: %v", i, err) |
| 321 | } |
| 322 | if want := fmt.Sprintf("%s-%d", winner, i); string(got) != want { |
| 323 | t.Fatalf("winning part %d = %q, want %q from one batch", i, got, want) |
| 324 | } |
| 325 | } |
| 326 | if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 { |
| 327 | t.Fatalf("concurrent export left staged files: matches=%v err=%v", matches, globErr) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | func TestExportFileFiltersSelectExpectedNativePattern(t *testing.T) { |
| 332 | t.Parallel() |
| 333 | tests := []struct { |
| 334 | mime string |
| 335 | ext string |
| 336 | want string |
| 337 | }{ |
| 338 | {mime: "application/pdf", ext: ".pdf", want: "*.pdf"}, |
| 339 | {mime: "image/png", ext: ".png", want: "*.png"}, |
| 340 | {mime: "application/octet-stream", ext: ".bin", want: "*.bin"}, |
| 341 | } |
| 342 | for _, test := range tests { |
| 343 | filters := exportFileFilters(test.mime, test.ext) |
| 344 | if len(filters) != 1 || filters[0].Pattern != test.want { |
| 345 | t.Fatalf("filters for %s = %#v, want pattern %q", test.mime, filters, test.want) |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 |