Add compile-time link validation for blog post internal links
- Add Blogex.LinkValidator module to validate /blog/{id}/{slug} semantics
- Add Blogex.LinkError exception with actionable error messages
- Integrate validation into Blogex.Blog via @before_compile callback
- Add unit tests (34) and integration tests (4) for link validation
- Add test fixtures (valid/invalid posts) in blogex/priv/blog/test/
Closes: validate-internal-link-semantics-in-blog-post-markdown-bodies-at-compile-time-h3hb
Closes: define-link-semantic-validation-logic-in-blogex-7syv
Closes: write-tests-for-link-semantic-validation-y30h
Closes: integrate-link-validation-into-blogexblog-compile-time-macro-1205
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
defmodule Blogex.BlogIntegrationTest do
|
||||
use ExUnit.Case
|
||||
|
||||
describe "compile-time link validation" do
|
||||
test "raises LinkError for invalid blog ID in link" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_invalid.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogInvalidBlogId do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "raises LinkError for invalid slug in link" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_invalid_slug.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogInvalidSlug do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "compiles successfully with valid links" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_valid.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogValid do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-01-valid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
assert TestBlogValid.title() == "Test Blog"
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "LinkError message includes post context" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_msg.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogErrorMsg do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
exception =
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
msg = Exception.message(exception)
|
||||
|
||||
assert msg =~ "invalid internal blog links"
|
||||
assert msg =~ "invalid-post"
|
||||
assert msg =~ "/blog/unknown/broken"
|
||||
assert msg =~ "/blog/engineering/My-Post"
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,236 @@
|
||||
defmodule Blogex.LinkValidatorTest do
|
||||
use ExUnit.Case
|
||||
alias Blogex.LinkValidator
|
||||
|
||||
describe "extract_links/1" do
|
||||
test "extracts internal blog links from markdown body" do
|
||||
body =
|
||||
"<p>Check out [hello world](/blog/engineering/hello-world) and [release v1](/blog/releases/v1-0-0).</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/releases/v1-0-0"
|
||||
]
|
||||
end
|
||||
|
||||
test "ignores external links" do
|
||||
body = "<p>See [GitHub](https://github.com) and [internal](/blog/engineering/post).</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post"]
|
||||
end
|
||||
|
||||
test "ignores non-blog internal links" do
|
||||
body = "<p>See [/about](/about) and [/blog/engineering/post](/blog/engineering/post).</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post"]
|
||||
end
|
||||
|
||||
test "returns empty list when no internal blog links" do
|
||||
body = "<p>Just external links: [GitHub](https://github.com).</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == []
|
||||
end
|
||||
|
||||
test "handles multiple links on one line" do
|
||||
body = "<p>[a](/blog/engineering/a) [b](/blog/releases/b) [c](/blog/engineering/c)</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/a",
|
||||
"/blog/releases/b",
|
||||
"/blog/engineering/c"
|
||||
]
|
||||
end
|
||||
|
||||
test "handles links with query strings" do
|
||||
body = "<p>[link](/blog/engineering/post?foo=bar)</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post?foo=bar"]
|
||||
end
|
||||
|
||||
test "handles links with anchor fragments" do
|
||||
body = "<p>[link](/blog/engineering/post#section)</p>"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post#section"]
|
||||
end
|
||||
|
||||
test "handles empty body" do
|
||||
assert LinkValidator.extract_links("") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_link/1" do
|
||||
test "validates correct engineering link" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/my-post") == :ok
|
||||
end
|
||||
|
||||
test "validates correct releases link" do
|
||||
assert LinkValidator.validate_link("/blog/releases/v1-0-0") == :ok
|
||||
end
|
||||
|
||||
test "rejects unknown blog ID" do
|
||||
assert LinkValidator.validate_link("/blog/unknown/post") ==
|
||||
{:error, "unknown blog ID: unknown"}
|
||||
end
|
||||
|
||||
test "rejects uppercase blog ID" do
|
||||
assert LinkValidator.validate_link("/blog/Engineering/post") ==
|
||||
{:error, "unknown blog ID: Engineering"}
|
||||
end
|
||||
|
||||
test "rejects empty slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/") ==
|
||||
{:error, "empty slug"}
|
||||
end
|
||||
|
||||
test "rejects slug with uppercase letters" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/My-Post") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: My-Post"}
|
||||
end
|
||||
|
||||
test "rejects slug with special characters" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello@world") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: hello@world"}
|
||||
end
|
||||
|
||||
test "rejects slug with spaces" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello world") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: hello world"}
|
||||
end
|
||||
|
||||
test "allows single-word slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello") == :ok
|
||||
end
|
||||
|
||||
test "allows hyphenated slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/my-cool-post") == :ok
|
||||
end
|
||||
|
||||
test "allows slug with numbers" do
|
||||
assert LinkValidator.validate_link("/blog/releases/v1-2-3") == :ok
|
||||
end
|
||||
|
||||
test "rejects slug starting with hyphen" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/-post") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: -post"}
|
||||
end
|
||||
|
||||
test "rejects slug ending with hyphen" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post-") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: post-"}
|
||||
end
|
||||
|
||||
test "rejects consecutive hyphens" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post--name") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: post--name"}
|
||||
end
|
||||
|
||||
test "returns :ok for link with query string and valid slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post?foo=bar") == :ok
|
||||
end
|
||||
|
||||
test "returns :ok for link with anchor fragment and valid slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post#section") == :ok
|
||||
end
|
||||
|
||||
test "rejects non-blog path" do
|
||||
assert LinkValidator.validate_link("/about") ==
|
||||
{:error, "not a blog link: /about"}
|
||||
end
|
||||
|
||||
test "rejects malformed link" do
|
||||
assert LinkValidator.validate_link("not-a-url") ==
|
||||
{:error, "not a blog link: not-a-url"}
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_links/1" do
|
||||
test "returns :ok when all links are valid" do
|
||||
links = [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/releases/v1-0-0"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == :ok
|
||||
end
|
||||
|
||||
test "returns errors for invalid links" do
|
||||
links = [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/unknown/post",
|
||||
"/blog/releases/My-Post"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == {
|
||||
:error,
|
||||
[
|
||||
{2, "/blog/unknown/post", "unknown blog ID: unknown"},
|
||||
{3, "/blog/releases/My-Post",
|
||||
"slug must be lowercase alphanumeric with hyphens: My-Post"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns :ok for empty list" do
|
||||
assert LinkValidator.validate_links([]) == :ok
|
||||
end
|
||||
|
||||
test "reports line numbers correctly" do
|
||||
links = [
|
||||
"/blog/engineering/ok",
|
||||
"/blog/bad/slug",
|
||||
"/blog/releases/ok"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == {
|
||||
:error,
|
||||
[{2, "/blog/bad/slug", "unknown blog ID: bad"}]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_body/2" do
|
||||
test "returns :ok when body has no internal blog links" do
|
||||
body = "<p>Just text, no links.</p>"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == :ok
|
||||
end
|
||||
|
||||
test "returns :ok when all links are valid" do
|
||||
body = "<p>[link](/blog/engineering/post)</p>"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == :ok
|
||||
end
|
||||
|
||||
test "returns errors with post context" do
|
||||
body = "<p>[link](/blog/unknown/post)</p>"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/post",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: nil
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "includes post_id in error tuples when provided" do
|
||||
body = "<p>[link](/blog/unknown/post)</p>"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "test-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/post",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: "test-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user