diff --git a/SPECGAPS.md b/SPECGAPS.md
index f86b280..6d04ed9 100644
--- a/SPECGAPS.md
+++ b/SPECGAPS.md
@@ -160,17 +160,17 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Claim | Spec | Gap | Path |
|---|---|---|---|---|
-| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
-| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
-| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
-| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
-| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
-| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
-| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
-| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
-| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
-| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
-| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
+| D3-1 | ~~PublishPost: content=null after publish~~ | post.allium:186 | **Resolved:** `refute published.content` added to frontmatter roundtrip test |
+| D3-2 | ~~PublishPost: old file deleted on path change~~ | engine_side_effects.allium:73-74 | **Resolved:** test already existed (`publish_post deletes old file when file path changes` at posts_test.exs:284) |
+| D3-3 | ~~UpsertPostTranslation: do_not_translate guard~~ | translation.allium:113 | **Resolved:** direct test added (do_not_translate guard rejects translation upsert with changeset error) |
+| D3-4 | ~~PublishTemplate: Liquid validation prerequisite~~ | template.allium:139 | **Resolved:** tests already existed (publish_template rejects invalid Liquid syntax, create_and_publish rejects invalid Liquid) |
+| D3-5 | ~~PublishScript: validation prerequisite~~ | script.allium:181 | **Resolved:** tests already existed (publish_script rejects invalid Lua syntax, create_and_publish rejects invalid Lua) |
+| D3-6 | ~~ExecuteMacro failure degrades to empty~~ | script.allium:199 | **Resolved:** `execute_macro` now returns `{:ok, ""}` on failure (was `{:error, reason}`), test updated, spec already correct |
+| D3-7 | ~~TemplateFrontmatter roundtrip~~ | template.allium:53 | **Resolved:** roundtrip test added (publish then parse back, compare all fields against DB record) |
+| D3-8 | ~~DefaultCategories for fresh project~~ | metadata.allium:60 | **Resolved:** test added (fresh project has article/aside/page/picture categories before any operations) |
+| D3-9 | ~~FtsIncludesTranslations~~ | translation.allium:178 | **Resolved:** test expanded (de/fr/es/it search terms all find the canonical post after reindex) |
+| D3-10 | ~~PostCanonicalUrl format~~ | post.allium:33-40 | **Resolved:** format test added (`/YYYY/MM/DD/slug/` via `LinksAndLanguages.post_path`) |
+| D3-11 | ~~Slug generation: German transliteration~~ | post.allium:14-22 | **Resolved:** 5 tests added (ß→ss, ö→o, ä→a, ü→u, mixed ÄÖÜäöüß→aouaouss) |
### D4. UI Test Coverage Gaps (whole-editor specs)
@@ -196,6 +196,6 @@ All reconciled to follow code. Specs must be self-consistent and match 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
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
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**~~ — all resolved: content=null assertion, old-file-deletion, DNT guard, validation prerequisites (already tested), macro failure degrades to empty, template roundtrip, default categories, FTS multi-language, canonical URL format, German transliteration expansion
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
diff --git a/lib/bds/scripting.ex b/lib/bds/scripting.ex
index 9376234..40cb452 100644
--- a/lib/bds/scripting.ex
+++ b/lib/bds/scripting.ex
@@ -71,7 +71,7 @@ defmodule BDS.Scripting do
{:error, reason} ->
Logger.warning("execute_macro failed for project #{project_id}: #{inspect(reason)}")
- {:error, reason}
+ {:ok, ""}
end
end
diff --git a/test/bds/metadata_test.exs b/test/bds/metadata_test.exs
index 4dbcadf..9f037c3 100644
--- a/test/bds/metadata_test.exs
+++ b/test/bds/metadata_test.exs
@@ -366,4 +366,13 @@ defmodule BDS.MetadataTest do
refute File.exists?(Path.join(meta_dir, "category-meta.json.tmp"))
refute File.exists?(Path.join(meta_dir, "publishing.json.tmp"))
end
+
+ test "fresh project has default categories before any operations", %{project: project} do
+ assert {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
+ assert "article" in metadata.categories
+ assert "aside" in metadata.categories
+ assert "page" in metadata.categories
+ assert "picture" in metadata.categories
+ assert length(metadata.categories) == 4
+ end
end
diff --git a/test/bds/post_translations_test.exs b/test/bds/post_translations_test.exs
index 9183b50..3ede77c 100644
--- a/test/bds/post_translations_test.exs
+++ b/test/bds/post_translations_test.exs
@@ -428,6 +428,30 @@ defmodule BDS.PostTranslationsTest do
defp wait_for_ai_tasks(count, attempts \\ 100)
+ test "do_not_translate guard prevents translation upsert via the API", %{
+ project: project
+ } do
+ assert {:ok, post} =
+ Posts.create_post(%{
+ project_id: project.id,
+ title: "DNT Guarded",
+ content: "Body",
+ language: "en"
+ })
+
+ assert {:ok, post} = Posts.update_post(post.id, %{do_not_translate: true})
+ assert post.do_not_translate == true
+
+ assert {:error, changeset} =
+ Posts.upsert_post_translation(post.id, "de", %{
+ title: "Sollte fehlschlagen",
+ content: "Inhalt"
+ })
+
+ assert changeset.errors[:do_not_translate] ==
+ {"cannot add translations when do_not_translate is true", []}
+ end
+
defp wait_for_ai_tasks(_count, 0) do
flunk("AI tasks did not reach expected state")
end
diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs
index 81efb1b..5cf9745 100644
--- a/test/bds/posts_test.exs
+++ b/test/bds/posts_test.exs
@@ -208,6 +208,7 @@ defmodule BDS.PostsTest do
assert {:ok, post} = BDS.Posts.update_post(post.id, %{do_not_translate: true})
assert {:ok, published} = BDS.Posts.publish_post(post.id)
assert published.status == :published
+ refute published.content
full_path = Path.join(temp_dir, published.file_path)
assert File.exists?(full_path)
@@ -315,6 +316,45 @@ defmodule BDS.PostsTest do
refute File.exists?(old_full)
end
+ test "German ß transliterates to ss" do
+ assert BDS.Slug.slugify("Straße") == "strasse"
+ end
+
+ test "German ö becomes o via NFD decomposition" do
+ assert BDS.Slug.slugify("Öl") == "ol"
+ end
+
+ test "German ä becomes a via NFD decomposition" do
+ assert BDS.Slug.slugify("Äpfel") == "apfel"
+ end
+
+ test "German ü becomes u via NFD decomposition" do
+ assert BDS.Slug.slugify("Über") == "uber"
+ end
+
+ test "mixed German characters transliterate correctly" do
+ assert BDS.Slug.slugify("ÄÖÜäöüß") == "aouaouss"
+ end
+
+ test "canonical post URL follows the format /YYYY/MM/DD/slug/", %{
+ project: project
+ } do
+ assert {:ok, post} =
+ BDS.Posts.create_post(%{
+ project_id: project.id,
+ title: "Canonical Format",
+ content: "Body"
+ })
+
+ created = BDS.Persistence.from_unix_ms!(post.created_at)
+ year = Integer.to_string(created.year)
+ month = String.pad_leading(Integer.to_string(created.month), 2, "0")
+ day = String.pad_leading(Integer.to_string(created.day), 2, "0")
+
+ canonical = BDS.Rendering.LinksAndLanguages.post_path(post, nil)
+ assert canonical == "/#{year}/#{month}/#{day}/canonical-format/"
+ end
+
test "delete_post removes the database row and published markdown file when present" do
temp_dir =
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")
diff --git a/test/bds/scripting/api_test.exs b/test/bds/scripting/api_test.exs
index 6684beb..806c69a 100644
--- a/test/bds/scripting/api_test.exs
+++ b/test/bds/scripting/api_test.exs
@@ -106,7 +106,7 @@ defmodule BDS.Scripting.ApiTest do
bad_source = "function render() error('boom') end"
- assert {:error, _reason} = BDS.Scripting.execute_macro(project.id, bad_source, [])
+ assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, [])
end
test "macro execution is bounded by its timeout budget (MacroTimeout)", %{project: project} do
@@ -125,7 +125,7 @@ defmodule BDS.Scripting.ApiTest do
)
end)
- assert {:error, :timeout} = result
+ assert {:ok, ""} = result
elapsed_ms = div(elapsed_us, 1000)
diff --git a/test/bds/search_test.exs b/test/bds/search_test.exs
index 4e4f16a..5261e06 100644
--- a/test/bds/search_test.exs
+++ b/test/bds/search_test.exs
@@ -552,4 +552,53 @@ defmodule BDS.SearchTest do
assert "hi" in languages
assert Enum.uniq(languages) == languages
end
+
+ test "search_posts finds translation text in multiple languages after reindex", %{
+ project: project
+ } do
+ assert {:ok, post} =
+ BDS.Posts.create_post(%{
+ project_id: project.id,
+ title: "Multi Lang",
+ content: "root body",
+ language: "en"
+ })
+
+ now = System.system_time(:second)
+ languages = [{"de", "Hallo Welt"}, {"fr", "Bonjour le monde"}, {"es", "Hola mundo"}, {"it", "Ciao mondo"}]
+
+ for {lang, content} <- languages do
+ Repo.query!(
+ """
+ INSERT INTO post_translations (
+ id, project_id, translation_for, language, title, excerpt, content, status,
+ created_at, updated_at, published_at, file_path, checksum
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ [
+ Ecto.UUID.generate(),
+ project.id,
+ post.id,
+ lang,
+ "Title #{lang}",
+ "Summary",
+ content,
+ "draft",
+ now,
+ now,
+ nil,
+ "",
+ nil
+ ]
+ )
+ end
+
+ assert :ok = BDS.Search.reindex_project(project.id)
+
+ for {_lang, content} <- languages do
+ search_term = String.split(content) |> List.last()
+ assert {:ok, results} = BDS.Search.search_posts(project.id, search_term, %{})
+ assert Enum.map(results.posts, & &1.id) == [post.id]
+ end
+ end
end
diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs
index 8c877e9..5abb187 100644
--- a/test/bds/templates_test.exs
+++ b/test/bds/templates_test.exs
@@ -554,6 +554,42 @@ defmodule BDS.TemplatesTest do
assert second.status == :published
end
+ test "template frontmatter roundtrips: written fields parsed back match the database record", %{
+ project: project,
+ temp_dir: temp_dir
+ } do
+ assert {:ok, template} =
+ BDS.Templates.create_template(%{
+ project_id: project.id,
+ title: "Roundtrip Layout",
+ kind: :list,
+ content: "",
+ enabled: true
+ })
+
+ assert {:ok, published} = BDS.Templates.publish_template(template.id)
+
+ full_path = Path.join(temp_dir, published.file_path)
+ assert File.exists?(full_path)
+
+ contents = File.read!(full_path)
+ assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(contents)
+
+ assert fields["id"] == published.id
+ assert fields["projectId"] == project.id
+ assert fields["slug"] == "roundtrip-layout"
+ assert fields["title"] == "Roundtrip Layout"
+ assert fields["kind"] == "list"
+ assert fields["enabled"] == true
+ assert fields["version"] == 1
+ assert is_integer(fields["createdAt"])
+ assert fields["createdAt"] == published.created_at
+ assert is_integer(fields["updatedAt"])
+ assert fields["updatedAt"] == published.updated_at
+
+ assert body == ""
+ end
+
test "rebuild_templates_from_files removes stale published default templates when no local template files exist",
%{
project: project