2026-06-21 13:15:06 +02:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
|
|
require "test_helper"
|
|
|
|
|
require "volumen/server"
|
|
|
|
|
require "rack/test"
|
|
|
|
|
require "tmpdir"
|
|
|
|
|
require "fileutils"
|
|
|
|
|
require "json"
|
|
|
|
|
|
|
|
|
|
class ServerTest < Minitest::Test
|
|
|
|
|
include Rack::Test::Methods
|
|
|
|
|
|
|
|
|
|
def app
|
|
|
|
|
Volumen::Server.configured(config: @config, store: @store)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def setup
|
|
|
|
|
@dir = Dir.mktmpdir
|
|
|
|
|
File.write(File.join(@dir, "hello.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Hello"
|
|
|
|
|
tags = ["intro"]
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Hello **world**.
|
|
|
|
|
POST
|
|
|
|
|
File.write(File.join(@dir, "draft.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Draft"
|
|
|
|
|
draft = true
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Secret.
|
|
|
|
|
POST
|
|
|
|
|
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
|
|
|
|
|
@store = Volumen::Store.new(@dir, default_lang: "en")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def teardown
|
|
|
|
|
FileUtils.remove_entry(@dir)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_site_endpoint
|
|
|
|
|
get "/api/volumen/site"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_posts_excludes_drafts
|
|
|
|
|
get "/api/volumen/posts"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal 1, data["total"]
|
|
|
|
|
assert_equal "hello", data["posts"].first["slug"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_post_detail_renders_html
|
|
|
|
|
get "/api/volumen/posts/hello"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_post_not_found
|
|
|
|
|
get "/api/volumen/posts/missing"
|
|
|
|
|
assert_equal 404, last_response.status
|
|
|
|
|
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_tags_endpoint
|
|
|
|
|
get "/api/volumen/tags"
|
|
|
|
|
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_cors_header_present
|
|
|
|
|
get "/api/volumen/site"
|
|
|
|
|
header = last_response.headers["Access-Control-Allow-Origin"] ||
|
|
|
|
|
last_response.headers["access-control-allow-origin"]
|
|
|
|
|
assert_equal "*", header
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_all_langs_post_appears_in_other_languages
|
|
|
|
|
File.write(File.join(@dir, "global.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Global"
|
|
|
|
|
lang = "en"
|
|
|
|
|
all_langs = true
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Everywhere.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/posts?lang=cs"
|
|
|
|
|
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
|
|
|
assert_includes slugs, "global"
|
|
|
|
|
refute_includes slugs, "hello"
|
|
|
|
|
end
|
2026-06-25 21:41:19 +02:00
|
|
|
|
|
|
|
|
def test_feed_endpoint
|
|
|
|
|
get "/api/volumen/feed.xml"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
|
|
|
|
|
body = last_response.body
|
2026-06-26 17:40:48 +02:00
|
|
|
assert_includes body, "<rss version=\"2.0\""
|
2026-06-25 21:41:19 +02:00
|
|
|
assert_includes body, "<title>Hello</title>"
|
|
|
|
|
refute_includes body, "Draft"
|
|
|
|
|
assert_includes body, "<language>en</language>"
|
|
|
|
|
end
|
2026-06-25 21:46:30 +02:00
|
|
|
|
|
|
|
|
def test_draft_post_detail_is_not_found
|
|
|
|
|
get "/api/volumen/posts/draft"
|
|
|
|
|
assert_equal 404, last_response.status
|
|
|
|
|
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
|
|
|
|
end
|
2026-06-25 21:51:32 +02:00
|
|
|
|
|
|
|
|
def test_sitemap_endpoint
|
|
|
|
|
get "/api/volumen/sitemap.xml"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
|
|
|
|
|
body = last_response.body
|
|
|
|
|
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
|
|
|
|
|
assert_includes body, "<loc>https://example.com/hello</loc>"
|
|
|
|
|
refute_includes body, "draft"
|
|
|
|
|
end
|
2026-06-25 21:55:33 +02:00
|
|
|
|
|
|
|
|
def test_cors_preflight
|
|
|
|
|
options "/api/volumen/site"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
|
|
|
|
|
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
|
|
|
|
|
end
|
2026-06-25 22:03:00 +02:00
|
|
|
|
|
|
|
|
def test_scheduled_post_is_hidden
|
|
|
|
|
future = (Date.today + 30).strftime("%Y-%m-%d")
|
|
|
|
|
File.write(File.join(@dir, "later.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Later"
|
|
|
|
|
publish_at = #{future}
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Not yet.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/posts"
|
|
|
|
|
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
|
|
|
refute_includes slugs, "later"
|
|
|
|
|
end
|
2026-06-25 22:03:40 +02:00
|
|
|
|
|
|
|
|
def test_json_feed_endpoint
|
|
|
|
|
get "/api/volumen/feed.json"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
|
|
|
|
|
assert_equal "My Blog", data["title"]
|
|
|
|
|
assert_equal 1, data["items"].length
|
|
|
|
|
assert_equal "Hello", data["items"].first["title"]
|
|
|
|
|
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
|
|
|
|
|
refute_includes data["items"].map { |i| i["title"] }, "Draft"
|
|
|
|
|
end
|
2026-06-25 22:04:44 +02:00
|
|
|
|
2026-06-26 17:40:48 +02:00
|
|
|
def test_post_summary_exposes_fediverse_creator
|
|
|
|
|
File.write(File.join(@dir, "attributed.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Attributed"
|
|
|
|
|
slug = "attributed"
|
|
|
|
|
fediverse_creator = "@ada@lovelace.org"
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Body.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/posts/attributed"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal "@ada@lovelace.org", data["fediverse_creator"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_post_summary_fediverse_creator_is_null_when_not_set
|
|
|
|
|
get "/api/volumen/posts/hello"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert_nil JSON.parse(last_response.body)["fediverse_creator"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_posts_list_summary_exposes_fediverse_creator
|
|
|
|
|
File.write(File.join(@dir, "fed.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Fed"
|
|
|
|
|
slug = "fed"
|
|
|
|
|
fediverse_creator = "@user@example.social"
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Body.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/posts"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
fed = data["posts"].find { |post| post["slug"] == "fed" }
|
|
|
|
|
assert_equal "@user@example.social", fed["fediverse_creator"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_site_endpoint_exposes_fediverse_creator_default
|
|
|
|
|
get "/api/volumen/site"
|
|
|
|
|
assert_predicate last_response, :ok?
|
|
|
|
|
assert JSON.parse(last_response.body).key?("fediverse_creator")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_rss_feed_includes_dc_creator_when_set
|
|
|
|
|
File.write(File.join(@dir, "rss.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Rss"
|
|
|
|
|
slug = "rss"
|
|
|
|
|
fediverse_creator = "@rss@example.com"
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Body.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/feed.xml"
|
|
|
|
|
assert_includes last_response.body, "<dc:creator>@rss@example.com</dc:creator>"
|
|
|
|
|
assert_includes last_response.body, "xmlns:dc=\"http://purl.org/dc/elements/1.1/\""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_rss_feed_omits_dc_creator_when_not_set
|
|
|
|
|
get "/api/volumen/feed.xml"
|
|
|
|
|
refute_includes last_response.body, "<dc:creator>"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_json_feed_includes_authors_from_post
|
|
|
|
|
File.write(File.join(@dir, "jsonfed.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Jsonfed"
|
|
|
|
|
slug = "jsonfed"
|
|
|
|
|
fediverse_creator = "@jf@example.io"
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Body.
|
|
|
|
|
POST
|
|
|
|
|
get "/api/volumen/feed.json"
|
|
|
|
|
item = JSON.parse(last_response.body)["items"].find { |i| i["id"] == "https://example.com/jsonfed" }
|
|
|
|
|
assert_equal [{ "name" => "@jf@example.io" }], item["authors"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_json_feed_falls_back_to_site_default_fediverse_creator
|
|
|
|
|
Dir.chdir(@dir) do
|
|
|
|
|
File.write("config.toml", <<~TOML)
|
|
|
|
|
[site]
|
|
|
|
|
fediverse_creator = "@owner@example.com"
|
|
|
|
|
TOML
|
|
|
|
|
end
|
|
|
|
|
@config = Volumen::Config.load(
|
|
|
|
|
path: File.join(@dir, "config.toml"),
|
|
|
|
|
overrides: { content: @dir }
|
|
|
|
|
)
|
|
|
|
|
get "/api/volumen/feed.json"
|
|
|
|
|
items = JSON.parse(last_response.body)["items"]
|
|
|
|
|
expected = [{ "name" => "@owner@example.com" }]
|
|
|
|
|
assert(items.all? { |i| i["authors"] == expected })
|
|
|
|
|
end
|
|
|
|
|
|
2026-06-25 22:04:44 +02:00
|
|
|
def test_search_by_title
|
|
|
|
|
get "/api/volumen/posts?q=Hello"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal 1, data["total"]
|
|
|
|
|
assert_equal "hello", data["posts"].first["slug"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_search_by_body
|
|
|
|
|
get "/api/volumen/posts?q=world"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal 1, data["total"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_search_no_match
|
|
|
|
|
get "/api/volumen/posts?q=nonexistent"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal 0, data["total"]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def test_search_is_case_insensitive
|
|
|
|
|
get "/api/volumen/posts?q=HELLO"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal 1, data["total"]
|
|
|
|
|
end
|
2026-06-26 20:36:16 +02:00
|
|
|
|
|
|
|
|
def test_pagination_has_next_and_prev
|
|
|
|
|
# Create enough posts to paginate
|
|
|
|
|
5.times do |i|
|
|
|
|
|
File.write(File.join(@dir, "post-#{i}.md"), <<~POST)
|
|
|
|
|
+++
|
|
|
|
|
title = "Post #{i}"
|
|
|
|
|
+++
|
|
|
|
|
|
|
|
|
|
Body #{i}.
|
|
|
|
|
POST
|
|
|
|
|
end
|
|
|
|
|
get "/api/volumen/posts?page=1&limit=2"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal true, data["has_next"]
|
|
|
|
|
assert_equal false, data["has_prev"]
|
|
|
|
|
|
|
|
|
|
get "/api/volumen/posts?page=2&limit=2"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal true, data["has_next"]
|
|
|
|
|
assert_equal true, data["has_prev"]
|
|
|
|
|
|
|
|
|
|
get "/api/volumen/posts?page=3&limit=2"
|
|
|
|
|
data = JSON.parse(last_response.body)
|
|
|
|
|
assert_equal false, data["has_next"]
|
|
|
|
|
assert_equal true, data["has_prev"]
|
|
|
|
|
end
|
2026-06-21 13:15:06 +02:00
|
|
|
end
|