diff --git a/lib/bds/generation/validation.ex b/lib/bds/generation/validation.ex index bfe0a34..9ce6610 100644 --- a/lib/bds/generation/validation.ex +++ b/lib/bds/generation/validation.ex @@ -15,6 +15,11 @@ defmodule BDS.Generation.Validation do alias BDS.Slug + # POSIX mtimes from File.stat/2 have second granularity while generation + # timestamps are milliseconds, so ordering within one second is unknowable; + # only treat a source file as newer when it is beyond this tolerance. + @mtime_granularity_tolerance_ms 1_000 + @spec generated_file_updated_at_map([map()]) :: map() def generated_file_updated_at_map(generated_files) do Map.new(generated_files, &{&1.relative_path, &1.updated_at}) @@ -139,7 +144,8 @@ defmodule BDS.Generation.Validation do effective_generated_at_ms = max(mtime_ms(html_stat), check.generated_updated_at_ms || 0) - if mtime_ms(post_stat) > effective_generated_at_ms do + if mtime_ms(post_stat) > + effective_generated_at_ms + @mtime_granularity_tolerance_ms do MapSet.put(acc, normalized_url_path) else acc diff --git a/test/bds/generation/validation_test.exs b/test/bds/generation/validation_test.exs new file mode 100644 index 0000000..375acdc --- /dev/null +++ b/test/bds/generation/validation_test.exs @@ -0,0 +1,97 @@ +defmodule BDS.Generation.ValidationTest do + use ExUnit.Case, async: true + + alias BDS.Generation.Validation + + @base_url "https://example.com/blog" + @post_url_path "/2026/06/01/sample-post" + + setup do + temp_dir = + Path.join(System.tmp_dir!(), "bds-validation-#{System.unique_integer([:positive])}") + + html_dir = Path.join(temp_dir, "html") + post_html_path = Path.join([html_dir, "2026", "06", "01", "sample-post", "index.html"]) + source_path = Path.join(temp_dir, "sample-post.md") + + File.mkdir_p!(Path.dirname(post_html_path)) + File.write!(Path.join(html_dir, "index.html"), "home") + File.write!(post_html_path, "post") + File.write!(source_path, "Sample body") + on_exit(fn -> File.rm_rf(temp_dir) end) + + %{html_dir: html_dir, post_html_path: post_html_path, source_path: source_path} + end + + defp compare(html_dir, source_path, generated_updated_at_ms) do + sitemap_xml = + Enum.join([ + ~s(), + ~s(), + ~s(#{@base_url}/), + ~s(#{@base_url}#{@post_url_path}/), + ~s() + ]) + + Validation.compare_sitemap_to_html(%{ + sitemap_xml: sitemap_xml, + base_url: @base_url, + html_dir: html_dir, + on_progress: nil, + post_timestamp_checks: [ + %{ + post_url_path: @post_url_path, + post_file_path: source_path, + generated_updated_at_ms: generated_updated_at_ms + } + ] + }) + end + + test "does not report a post as updated when its source mtime second adjoins the generation timestamp", + %{html_dir: html_dir, post_html_path: post_html_path, source_path: source_path} do + source_mtime = System.os_time(:second) + + File.touch!(source_path, source_mtime) + File.touch!(post_html_path, source_mtime - 5) + File.touch!(Path.join(html_dir, "index.html"), source_mtime - 5) + + generated_updated_at_ms = source_mtime * 1000 - 100 + + result = compare(html_dir, source_path, generated_updated_at_ms) + + assert result.missing_url_paths == [] + assert result.extra_url_paths == [] + assert result.updated_post_url_paths == [] + end + + test "does not report a post as updated when its source mtime is in the same second as the generation timestamp", + %{html_dir: html_dir, post_html_path: post_html_path, source_path: source_path} do + source_mtime = System.os_time(:second) + + File.touch!(source_path, source_mtime) + File.touch!(post_html_path, source_mtime - 5) + File.touch!(Path.join(html_dir, "index.html"), source_mtime - 5) + + generated_updated_at_ms = source_mtime * 1000 + 500 + + result = compare(html_dir, source_path, generated_updated_at_ms) + + assert result.updated_post_url_paths == [] + end + + test "reports a post as updated when its source mtime is beyond the granularity tolerance", + %{html_dir: html_dir, post_html_path: post_html_path, source_path: source_path} do + source_mtime = System.os_time(:second) + + File.touch!(source_path, source_mtime) + File.touch!(post_html_path, source_mtime - 5) + File.touch!(Path.join(html_dir, "index.html"), source_mtime - 5) + + generated_updated_at_ms = (source_mtime - 5) * 1000 + + result = compare(html_dir, source_path, generated_updated_at_ms) + + assert result.updated_post_url_paths == [@post_url_path] + end +end diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index cfb593e..d797636 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -890,8 +890,8 @@ defmodule BDS.GenerationTest do source_path = Path.join([temp_dir, published_post.file_path]) extra_path = Path.join([temp_dir, "html", "obsolete", "index.html"]) + backdate_generated_outputs(project.id, temp_dir) File.rm!(post_file_path) - Process.sleep(1200) File.write!(source_path, File.read!(source_path) <> "\n") File.mkdir_p!(Path.dirname(extra_path)) File.write!(extra_path, "obsolete") @@ -973,12 +973,12 @@ defmodule BDS.GenerationTest do updated_post_source_path = Path.join([temp_dir, published_updated_post.file_path]) extra_route_path = Path.join([temp_dir, "html", "obsolete", "deep", "index.html"]) + backdate_generated_outputs(project.id, temp_dir) File.rm!(sitemap_path) File.rm!(missing_post_html_path) File.mkdir_p!(Path.dirname(extra_route_path)) File.write!(extra_route_path, "obsolete") - Process.sleep(1200) File.write!(updated_post_source_path, File.read!(updated_post_source_path) <> "\n") assert {:ok, report} = BDS.Generation.validate_site(project.id) @@ -1317,9 +1317,9 @@ defmodule BDS.GenerationTest do post_html_path = Path.join([temp_dir, "html", post_path]) post_source_path = Path.join([temp_dir, published_post.file_path]) + backdate_generated_outputs(project.id, temp_dir) before_stat = File.stat!(post_html_path) - Process.sleep(1200) File.write!(post_source_path, File.read!(post_source_path) <> "\n") assert {:ok, report} = BDS.Generation.validate_site(project.id) @@ -1340,6 +1340,23 @@ defmodule BDS.GenerationTest do assert clean_report.updated_post_url_paths == [] end + # Pushes generation timestamps and html mtimes far into the past so a + # subsequent source-file write is newer by more than the second-granularity + # mtime tolerance, without sleeping across real second boundaries. + defp backdate_generated_outputs(project_id, temp_dir) do + past_posix = System.os_time(:second) - 120 + + Repo.update_all( + from(g in BDS.Generation.GeneratedFileHash, where: g.project_id == ^project_id), + set: [updated_at: past_posix * 1000] + ) + + [temp_dir, "html", "**"] + |> Path.join() + |> Path.wildcard() + |> Enum.each(&File.touch!(&1, past_posix)) + end + defp relative_path_to_url_path(relative_path) do cleaned = relative_path