diff --git a/SPECGAPS.md b/SPECGAPS.md index ad4208d..c41e5ac 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -128,7 +128,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. | D1-11 | ~~ChatContextTruncation invariant~~ | ai.allium:375-379 | **Resolved:** test added in `ai_test.exs` — a catalog model with a 2,000-token context window plus 40 large seeded turns forces truncation; the captured chat request keeps the system prompt as the first message, drops the oldest pairs first (surviving markers form a contiguous newest suffix, oldest absent), and always retains the newest user turn | | D1-12 | ~~BoundedToolLoop enforcement~~ | ai.allium:381-385 | **Resolved:** the round cap is now read from `config.chat_max_tool_rounds` (`config :bds, :chat, max_tool_rounds: 10`) via `chat_max_tool_rounds/0` in chat.ex instead of a hardcoded attribute, matching the spec wording; test added in `ai_test.exs` — a `LoopingToolRuntime` that always returns another tool call (never a final answer) with `max_tool_rounds: 3` ends with `{:error, %{kind: :tool_loop_exhausted}}` after exactly 3 runtime calls (the `rounds_left == 0` round short-circuits before contacting the runtime) | | D1-13 | ~~DiscardPostChangesSideEffects~~ | engine_side_effects.allium:99-104 | **Resolved:** test added in `posts_test.exs` — a published post is dirtied with unsaved title/content edits (re-indexing the dirty text in FTS), then `discard_post_changes/1` restores the published file version (status=published, content=nil, original title) and re-syncs the FTS index so the published terms are searchable again and the discarded edits are gone | -| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated | +| D1-14 | ~~ReplaceMediaFileSideEffects~~ | engine_side_effects.allium:128-134 | **Resolved:** 3 tests added in `media_test.exs` — `replace_media_file/2` copies the new image over the existing path, updates the row (checksum/size/width/height), and regenerates all thumbnails synchronously (present immediately after the call, no `.bak` backup left); identical-checksum replace is a no-op (`{:ok, nil}`); unknown media id returns `{:error, :not_found}` | | D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test | | D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced | | D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected | diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index cbac589..977744c 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -119,6 +119,79 @@ defmodule BDS.MediaTest do end) end + test "replace_media_file copies the new file over the path, updates the row, and regenerates thumbnails synchronously", + %{project: project, temp_dir: temp_dir} do + source_path = Path.join(temp_dir, "original.jpg") + File.write!(source_path, Image.new!(4, 4, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg")) + + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + assert media.width == 4 + assert media.height == 4 + + thumbnail_paths = BDS.Media.thumbnail_paths(media) + + # Delete thumbnails so their presence after the replace proves regeneration ran. + Enum.each(Map.values(thumbnail_paths), fn path -> + File.rm!(Path.join(temp_dir, path)) + end) + + replacement_path = Path.join(temp_dir, "replacement.jpg") + File.write!(replacement_path, Image.new!(6, 8, color: [0, 0, 255]) |> Image.write!(:memory, suffix: ".jpg")) + replacement_binary = File.read!(replacement_path) + + assert {:ok, updated} = BDS.Media.replace_media_file(media.id, replacement_path) + + refute updated.checksum == media.checksum + assert updated.width == 6 + assert updated.height == 8 + assert updated.size == byte_size(replacement_binary) + + # New file content is on disk at the same path. + assert File.read!(Path.join(temp_dir, updated.file_path)) == replacement_binary + + # Thumbnails were regenerated synchronously (present immediately, no backup left behind). + Enum.each(Map.values(BDS.Media.thumbnail_paths(updated)), fn path -> + assert File.exists?(Path.join(temp_dir, path)) + end) + + refute File.exists?(Path.join(temp_dir, updated.file_path) <> ".bak") + end + + test "replace_media_file is a no-op when the new file matches the current checksum", %{ + project: project, + temp_dir: temp_dir + } do + source_path = Path.join(temp_dir, "same.jpg") + File.write!(source_path, Image.new!(4, 4, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg")) + + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + # First replace establishes a stored checksum (import does not compute one). + new_path = Path.join(temp_dir, "blue.jpg") + File.write!(new_path, Image.new!(6, 8, color: [0, 0, 255]) |> Image.write!(:memory, suffix: ".jpg")) + + assert {:ok, updated} = BDS.Media.replace_media_file(media.id, new_path) + refute is_nil(updated.checksum) + + # Replacing with the identical file is a no-op. + identical_path = Path.join(temp_dir, "blue_copy.jpg") + File.cp!(new_path, identical_path) + + assert {:ok, nil} = BDS.Media.replace_media_file(media.id, identical_path) + end + + test "replace_media_file returns {:error, :not_found} for an unknown media id", %{ + temp_dir: temp_dir + } do + replacement_path = Path.join(temp_dir, "orphan.jpg") + File.write!(replacement_path, Image.new!(4, 4, color: [0, 255, 0]) |> Image.write!(:memory, suffix: ".jpg")) + + assert {:error, :not_found} = BDS.Media.replace_media_file("does-not-exist", replacement_path) + end + test "deleting a post rewrites linked media sidecars to remove that post id", %{ project: project, temp_dir: temp_dir