D2-10/D2-12/D2-15/D2-16: close out remaining D2 spec gaps with tests + validate_media implementation
This commit is contained in:
18
SPECGAPS.md
18
SPECGAPS.md
@@ -147,14 +147,14 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
| ~~D2-7~~ | ~~ConditionalPostFields: nil fields absent from frontmatter~~ | frontmatter.allium:398 | **Resolved:** test added — post with nil excerpt/author/language → those fields absent from published file, 3 refute assertions |
|
| ~~D2-7~~ | ~~ConditionalPostFields: nil fields absent from frontmatter~~ | frontmatter.allium:398 | **Resolved:** test added — post with nil excerpt/author/language → those fields absent from published file, 3 refute assertions |
|
||||||
| ~~D2-8~~ | ~~ConditionalMediaFields: nil fields absent from sidecar~~ | frontmatter.allium:417 | **Resolved:** added width/height assertions to nil-fields-absent test |
|
| ~~D2-8~~ | ~~ConditionalMediaFields: nil fields absent from sidecar~~ | frontmatter.allium:417 | **Resolved:** added width/height assertions to nil-fields-absent test |
|
||||||
| D2-9 | ~~max_posts_per_page 1..500 constraint~~ | metadata.allium:75-77 | **Resolved:** 5 tests added (0→1, -5→1, 1000→500, nil→50, non-numeric→50) |
|
| D2-9 | ~~max_posts_per_page 1..500 constraint~~ | metadata.allium:75-77 | **Resolved:** 5 tests added (0→1, -5→1, 1000→500, nil→50, non-numeric→50) |
|
||||||
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
|
| D2-10 | ~~SandboxedExecution: restricted capabilities blocked~~ | script.allium:84-88 | **Resolved:** 6 tests added (os.execute, os.rename, io.open for write, require, dofile, loadlib all blocked) |
|
||||||
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
|
| D2-11 | ~~TransformToastBudget enforcement~~ | script.allium:251-258 | **Resolved:** 2 tests already existed (per-script cap + total budget) |
|
||||||
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
|
| D2-12 | ~~ProgressThrottled: 250ms throttle~~ | task.allium:110-113 | **Resolved:** 2 tests added (rapid reports within 250ms dropped; value 1.0 bypasses throttle) |
|
||||||
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
|
| D2-13 | ~~archived→draft transition~~ | post.allium:121 | **Resolved:** 4 tests already existed (unarchive→draft, restores content from disk, rejects non-archived, not_found) |
|
||||||
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
|
| D2-14 | ~~archived→published transition~~ | post.allium:122 | **Resolved:** test already existed (publish_post republishes archived posts without losing body or published_at) |
|
||||||
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
|
| D2-15 | ~~AppNoopNotifier: app writes don't produce notification rows~~ | cli_sync.allium:64-68 | **Resolved:** test added (post create, media import, metadata update do not produce notification rows) |
|
||||||
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
|
| D2-16 | ~~ValidateMedia rule~~ | media_processing.allium:318-343 | **Resolved:** `validate_media/1` implemented in media.ex, 5 tests added (healthy, missing_binary, missing_sidecar, orphan, linked_not_orphan) |
|
||||||
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
|
| D2-17 | ~~ContentHashSkipsUnchanged during reindex~~ | embedding.allium:199-202 | **Resolved:** 2 tests already existed (sync_post skips when hash matches; index_unindexed skips unchanged) |
|
||||||
|
|
||||||
### D3. Partial Test Coverage (needs expansion)
|
### D3. Partial Test Coverage (needs expansion)
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
||||||
4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file
|
4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file
|
||||||
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
||||||
6. **D2-1 through D2-17** — untested rules
|
6. ~~**D2-1 through D2-17**~~ — all resolved: `max_posts_per_page` constraint, sandboxed execution, transform toast budget, progress throttle, archived→draft/published transitions, AppNoopNotifier, validate_media implementation+tests, content_hash skip on reindex
|
||||||
7. **D3-1 through D3-11** — partial test coverage
|
7. **D3-1 through D3-11** — partial test coverage
|
||||||
8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting
|
8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting
|
||||||
9. **D4-1 through D4-7** — UI test coverage
|
9. **D4-1 through D4-7** — UI test coverage
|
||||||
|
|||||||
@@ -349,6 +349,53 @@ defmodule BDS.Media do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_media(String.t()) :: [%{media_id: String.t(), issue: String.t()}]
|
||||||
|
def validate_media(project_id) do
|
||||||
|
project = BDS.Projects.get_project!(project_id)
|
||||||
|
data_dir = BDS.Projects.project_data_dir(project)
|
||||||
|
|
||||||
|
Repo.all(from m in Media, where: m.project_id == ^project_id)
|
||||||
|
|> Enum.flat_map(fn media ->
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
binary_path = Path.join(data_dir, media.file_path)
|
||||||
|
issues = if File.exists?(binary_path), do: issues, else: [%{media_id: media.id, issue: "missing_binary"} | issues]
|
||||||
|
|
||||||
|
sidecar_path = Path.join(data_dir, media.sidecar_path)
|
||||||
|
issues = if File.exists?(sidecar_path), do: issues, else: [%{media_id: media.id, issue: "missing_sidecar"} | issues]
|
||||||
|
|
||||||
|
issues =
|
||||||
|
if BDS.Media.FileOps.image_mime?(media.mime_type) do
|
||||||
|
thumbnails = BDS.Media.Thumbnails.thumbnail_paths(media)
|
||||||
|
|
||||||
|
Enum.reduce([:small, :medium, :large, :ai], issues, fn size, acc ->
|
||||||
|
thumb_path = Path.join(data_dir, Map.fetch!(thumbnails, size))
|
||||||
|
|
||||||
|
if File.exists?(thumb_path),
|
||||||
|
do: acc,
|
||||||
|
else: [%{media_id: media.id, issue: "missing_thumbnail_#{size}"} | acc]
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
issues
|
||||||
|
end
|
||||||
|
|
||||||
|
issues =
|
||||||
|
if BDS.Media.FileOps.image_mime?(media.mime_type) and File.exists?(binary_path) do
|
||||||
|
case BDS.Media.FileOps.image_dimensions(binary_path, media.mime_type) do
|
||||||
|
{nil, nil} -> [%{media_id: media.id, issue: "corrupted"} | issues]
|
||||||
|
_ -> issues
|
||||||
|
end
|
||||||
|
else
|
||||||
|
issues
|
||||||
|
end
|
||||||
|
|
||||||
|
linked_posts = BDS.Media.Linking.list_linked_posts(media.id)
|
||||||
|
issues = if linked_posts == [], do: [%{media_id: media.id, issue: "orphan"} | issues], else: issues
|
||||||
|
|
||||||
|
Enum.reverse(issues)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp log_thumbnail_error(:ok, _media_id), do: :ok
|
defp log_thumbnail_error(:ok, _media_id), do: :ok
|
||||||
|
|
||||||
defp log_thumbnail_error({:error, reason}, media_id) do
|
defp log_thumbnail_error({:error, reason}, media_id) do
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ defmodule BDS.CliSyncTest do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "app-side writes do not produce notification rows (AppNoopNotifier)", %{} do
|
||||||
|
existing_before_create = Repo.aggregate(BDS.CliSync.Notification, :count)
|
||||||
|
|
||||||
|
# Perform a few app-side operations — post create, media import, metadata
|
||||||
|
# update — none should leave a notification row behind.
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-app-noop-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "AppNoop", data_path: temp_dir})
|
||||||
|
|
||||||
|
{:ok, _post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "App-Created",
|
||||||
|
content: "body"
|
||||||
|
})
|
||||||
|
|
||||||
|
source_path = Path.join(temp_dir, "image.png")
|
||||||
|
File.write!(source_path, "fake png")
|
||||||
|
{:ok, _media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
|
||||||
|
{:ok, _metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{name: "Renamed"})
|
||||||
|
|
||||||
|
existing_after = Repo.aggregate(BDS.CliSync.Notification, :count)
|
||||||
|
assert existing_after == existing_before_create
|
||||||
|
end
|
||||||
|
|
||||||
test "cli mutations are written to db_notifications, processed on file change, and marked seen" do
|
test "cli mutations are written to db_notifications, processed on file change, and marked seen" do
|
||||||
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
|
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
|
||||||
assert notification.from_cli == true
|
assert notification.from_cli == true
|
||||||
|
|||||||
@@ -773,6 +773,65 @@ defmodule BDS.MediaTest do
|
|||||||
|> Image.open!()
|
|> Image.open!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "validate_media (D2-16)" do
|
||||||
|
test "reports no issues for healthy media with a post link", %{project: project, temp_dir: temp_dir} do
|
||||||
|
source_path = Path.join(temp_dir, "sample.png")
|
||||||
|
File.write!(source_path, sample_image_binary(".png"))
|
||||||
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
|
||||||
|
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
|
||||||
|
assert [] == BDS.Media.validate_media(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reports missing binary file", %{project: project, temp_dir: temp_dir} do
|
||||||
|
source_path = Path.join(temp_dir, "sample.txt")
|
||||||
|
File.write!(source_path, "hello")
|
||||||
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
media_id = media.id
|
||||||
|
|
||||||
|
data_dir = BDS.Projects.project_data_dir(project)
|
||||||
|
File.rm!(Path.join(data_dir, media.file_path))
|
||||||
|
|
||||||
|
issues = BDS.Media.validate_media(project.id)
|
||||||
|
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_binary"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reports missing sidecar", %{project: project, temp_dir: temp_dir} do
|
||||||
|
source_path = Path.join(temp_dir, "sample.txt")
|
||||||
|
File.write!(source_path, "hello")
|
||||||
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
media_id = media.id
|
||||||
|
|
||||||
|
data_dir = BDS.Projects.project_data_dir(project)
|
||||||
|
File.rm!(Path.join(data_dir, media.sidecar_path))
|
||||||
|
|
||||||
|
issues = BDS.Media.validate_media(project.id)
|
||||||
|
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_sidecar"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reports orphan media (not linked to any post)", %{project: project, temp_dir: temp_dir} do
|
||||||
|
source_path = Path.join(temp_dir, "sample.txt")
|
||||||
|
File.write!(source_path, "hello")
|
||||||
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
media_id = media.id
|
||||||
|
|
||||||
|
issues = BDS.Media.validate_media(project.id)
|
||||||
|
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "orphan"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not report orphan when linked to a post", %{project: project, temp_dir: temp_dir} do
|
||||||
|
source_path = Path.join(temp_dir, "sample.txt")
|
||||||
|
File.write!(source_path, "hello")
|
||||||
|
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||||
|
|
||||||
|
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
|
||||||
|
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
|
||||||
|
|
||||||
|
issues = BDS.Media.validate_media(project.id)
|
||||||
|
refute Enum.any?(issues, &(&1.issue == "orphan"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp assert_images_match!(left, right) do
|
defp assert_images_match!(left, right) do
|
||||||
assert Image.shape(left) == Image.shape(right)
|
assert Image.shape(left) == Image.shape(right)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,42 @@ defmodule BDS.Scripting.LuaTest do
|
|||||||
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
|
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks os.execute" do
|
||||||
|
source = "function main() os.execute('echo hacked') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks os.rename" do
|
||||||
|
source = "function main() os.rename('/etc/passwd', '/tmp/hacked') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks io.open for writing" do
|
||||||
|
source = "function main() io.open('/tmp/hacked', 'w') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks require" do
|
||||||
|
source = "function main() require('socket') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks dofile" do
|
||||||
|
source = "function main() dofile('/etc/hosts') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sandbox blocks loadlib" do
|
||||||
|
source = "function main() package.loadlib('libc.so', 'system') end"
|
||||||
|
|
||||||
|
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
test "enforces reduction limits" do
|
test "enforces reduction limits" do
|
||||||
source = "function main() while true do end end"
|
source = "function main() while true do end end"
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,34 @@ defmodule BDS.TasksTest do
|
|||||||
assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed
|
assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "progress reports within 250ms throttle window are silently dropped" do
|
||||||
|
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress")
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||||
|
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
|
||||||
|
assert task_id = task.id
|
||||||
|
# The 250ms throttle has not elapsed, so progress stays at 0.25.
|
||||||
|
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25
|
||||||
|
|
||||||
|
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "progress report with value 1.0 bypasses the throttle" do
|
||||||
|
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress")
|
||||||
|
|
||||||
|
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||||
|
|
||||||
|
# A completion report (1.0) must go through even if throttled.
|
||||||
|
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
|
||||||
|
|
||||||
|
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
|
||||||
|
assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
|
||||||
|
|
||||||
|
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||||
|
end
|
||||||
|
|
||||||
test "external tasks are registered as running and can report progress and complete" do
|
test "external tasks are registered as running and can report progress and complete" do
|
||||||
assert {:ok, task} =
|
assert {:ok, task} =
|
||||||
BDS.Tasks.register_external_task("preview build", %{
|
BDS.Tasks.register_external_task("preview build", %{
|
||||||
|
|||||||
Reference in New Issue
Block a user