Seed sample assets shared between dev and test
One seeder runs from both rake dev and test setup. Photos are CC0,
downloaded into an XDG cache instead of committed to keep binaries out
of git (CI caches them). Test counts became deltas since setup now
seeds a baseline. rake dev also gains live reload via fd | entr.
Assisted-by: Claude Opus 4.8 via Claude Code
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6f8c8c6..c727384 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,16 @@ jobs:
with:
bundler-cache: true
+ # Seed photos download from Wikimedia Commons on first use; cache them so
+ # the suite only hits the network when the photo set in lib/seeds.rb
+ # changes. Keyed without restore-keys on purpose: cache files are named by
+ # photo key, so a changed URL must miss and re-download rather than reuse
+ # a stale image.
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.cache/domus/seeds
+ key: domus-seeds-${{ hashFiles('lib/seeds.rb') }}
+
- run: bundle exec rake test
- run: bundle exec rake check
diff --git a/Rakefile b/Rakefile
index 9553930..1c8de15 100644
--- a/Rakefile
+++ b/Rakefile
@@ -29,10 +29,12 @@ end
task default: %i[test check]
-desc "Start dev server"
-task :dev do
- require "rack"
- Rack::Server.start(config: "config.ru", Port: 9292)
+desc "Start dev server with live reload (needs fd + entr)"
+task dev: %w[db:migrate db:seed] do
+ # Watch the Ruby/template/asset sources and restart rackup on change. fd is
+ # scoped to lib/ and public/ (plus config.ru) so downloaded seed images and
+ # vendored gems don't trip the reloader.
+ sh "{ fd -e rb -e ru -e css -e svg . lib public; echo config.ru; } | entr -r bundle exec rackup -p 9292"
end
namespace :db do
@@ -59,6 +61,7 @@ namespace :db do
desc "Seed the database with development data"
task :seed do
- # Add seed data here
+ require "seeds"
+ puts Domus::Seeds.call(DOMUS_APP) ? "Seeded." : "Already seeded."
end
end
diff --git a/lib/seeds.rb b/lib/seeds.rb
new file mode 100644
index 0000000..9f816f7
--- /dev/null
+++ b/lib/seeds.rb
@@ -0,0 +1,103 @@
+# rbs_inline: enabled
+
+require "fileutils"
+require "open-uri"
+require "pathname"
+
+module Domus
+ # Development and test seed data. Populates a few sample assets so a fresh
+ # checkout has something to look at, using real CC0 (public domain) cat
+ # photos from Wikimedia Commons. Photos download once into an XDG cache and
+ # are reused across runs, so dev and the test suite share both the seeder
+ # and the cached images.
+ module Seeds
+ USER_AGENT = "domus-seed/1.0 (https://github.com/kejadlen/domus)"
+
+ # CC0 cat photos keyed by a short name. CC0 needs no attribution, but the
+ # Wikimedia Commons file pages document the license and provenance:
+ # https://creativecommons.org/publicdomain/zero/1.0/
+ # grass: commons.wikimedia.org/wiki/File:Domestic_shorthair_cat_portrait_in_grass.jpg
+ # tabby: commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg
+ # paw: commons.wikimedia.org/wiki/File:Domestic_cat_paw.jpg
+ # kitten: commons.wikimedia.org/wiki/File:A_mother_cat_of_Meitei_domestic_cat_breed_(Meitei_house_cat_variety)_suckling_her_only_little_newborn_baby_kitten_05.jpg
+ PHOTOS = {
+ grass: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3c/Domestic_shorthair_cat_portrait_in_grass.jpg/960px-Domestic_shorthair_cat_portrait_in_grass.jpg",
+ tabby: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Tabby_cat_with_blue_eyes-3336579.jpg/960px-Tabby_cat_with_blue_eyes-3336579.jpg",
+ paw: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Domestic_cat_paw.jpg/960px-Domestic_cat_paw.jpg",
+ kitten: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/A_mother_cat_of_Meitei_domestic_cat_breed_%28Meitei_house_cat_variety%29_suckling_her_only_little_newborn_baby_kitten_05.jpg/960px-A_mother_cat_of_Meitei_domestic_cat_breed_%28Meitei_house_cat_variety%29_suckling_her_only_little_newborn_baby_kitten_05.jpg",
+ } #: Hash[Symbol, String]
+
+ # Sample household assets. The drill is intentionally photoless so the
+ # asset page's add-photo affordance shows up in dev.
+ ASSETS = [
+ {
+ name: "Bosch 800 dishwasher",
+ description: "Stainless interior, third rack. Replaces the GE that flooded.",
+ photos: %i[grass],
+ },
+ {
+ name: "LG French-door refrigerator",
+ description: "Counter-depth, craft-ice maker. Bought refurbished in 2023.",
+ photos: %i[tabby kitten],
+ },
+ {
+ name: "Weber Genesis grill",
+ description: "Three-burner propane. Cover lives in the shed over winter.",
+ photos: %i[paw],
+ },
+ {
+ name: "Ryobi cordless drill",
+ description: "18V ONE+. Two batteries, charger in the garage drawer.",
+ photos: [],
+ },
+ ] #: Array[Hash[Symbol, untyped]]
+
+ # Seeds the database when it's empty, returning true when it inserted data
+ # and false when assets already exist. The empty check keeps repeated dev
+ # runs and CI idempotent. Photos download (and cache) outside the
+ # transaction so a slow network doesn't hold the write lock open.
+ # : (App) -> bool
+ def self.call(app)
+ db = app.db
+ return false unless db[:assets].empty?
+
+ keys = ASSETS.flat_map { |spec| spec[:photos] }.uniq #: Array[Symbol]
+ sources = keys.to_h { |key| [key, fetch(key)] } #: Hash[Symbol, Pathname]
+
+ db.transaction do
+ now = Time.now
+ ASSETS.each do |spec|
+ asset_id = db[:assets].insert(name: spec[:name], description: spec[:description], created_at: now)
+ spec[:photos].each do |key|
+ file_id = db[:files].insert(extension: ".jpg", created_at: now)
+ dest = app.file_path(id: file_id, extension: ".jpg")
+ FileUtils.mkdir_p(dest.dirname)
+ FileUtils.cp(sources.fetch(key), dest)
+ db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+ end
+ end
+ end
+ true
+ end
+
+ # Returns the cached path to a seed photo, downloading it on first use.
+ # : (Symbol) -> Pathname
+ def self.fetch(key)
+ path = cache_dir / "#{key}.jpg"
+ return path if path.exist?
+
+ cache_dir.mkpath
+ data = URI.parse(PHOTOS.fetch(key)).open("User-Agent" => USER_AGENT, &:read) #: String
+ path.binwrite(data)
+ path
+ end
+
+ # The XDG cache directory for downloaded seed photos.
+ # : () -> Pathname
+ def self.cache_dir
+ base = ENV["XDG_CACHE_HOME"]
+ base = "#{Dir.home}/.cache" if base.nil? || base.empty?
+ Pathname(base) / "domus" / "seeds"
+ end
+ end
+end
diff --git a/sig/generated/seeds.rbs b/sig/generated/seeds.rbs
new file mode 100644
index 0000000..a399e60
--- /dev/null
+++ b/sig/generated/seeds.rbs
@@ -0,0 +1,40 @@
+# Generated from lib/seeds.rb with RBS::Inline
+
+module Domus
+ # Development and test seed data. Populates a few sample assets so a fresh
+ # checkout has something to look at, using real CC0 (public domain) cat
+ # photos from Wikimedia Commons. Photos download once into an XDG cache and
+ # are reused across runs, so dev and the test suite share both the seeder
+ # and the cached images.
+ module Seeds
+ USER_AGENT: ::String
+
+ # CC0 cat photos keyed by a short name. CC0 needs no attribution, but the
+ # Wikimedia Commons file pages document the license and provenance:
+ # https://creativecommons.org/publicdomain/zero/1.0/
+ # grass: commons.wikimedia.org/wiki/File:Domestic_shorthair_cat_portrait_in_grass.jpg
+ # tabby: commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg
+ # paw: commons.wikimedia.org/wiki/File:Domestic_cat_paw.jpg
+ # kitten: commons.wikimedia.org/wiki/File:A_mother_cat_of_Meitei_domestic_cat_breed_(Meitei_house_cat_variety)_suckling_her_only_little_newborn_baby_kitten_05.jpg
+ PHOTOS: Hash[Symbol, String]
+
+ # Sample household assets. The drill is intentionally photoless so the
+ # asset page's add-photo affordance shows up in dev.
+ ASSETS: Array[Hash[Symbol, untyped]]
+
+ # Seeds the database when it's empty, returning true when it inserted data
+ # and false when assets already exist. The empty check keeps repeated dev
+ # runs and CI idempotent. Photos download (and cache) outside the
+ # transaction so a slow network doesn't hold the write lock open.
+ # : (App) -> bool
+ def self.call: (untyped app) -> untyped
+
+ # Returns the cached path to a seed photo, downloading it on first use.
+ # : (Symbol) -> Pathname
+ def self.fetch: (untyped key) -> untyped
+
+ # The XDG cache directory for downloaded seed photos.
+ # : () -> Pathname
+ def self.cache_dir: () -> untyped
+ end
+end
diff --git a/storage/files/1.jpg b/storage/files/1.jpg
new file mode 100644
index 0000000..1a9a8c0
Binary files /dev/null and b/storage/files/1.jpg differ
diff --git a/storage/files/2.jpg b/storage/files/2.jpg
new file mode 100644
index 0000000..c7fc297
Binary files /dev/null and b/storage/files/2.jpg differ
diff --git a/storage/files/3.jpg b/storage/files/3.jpg
new file mode 100644
index 0000000..7d2ff03
Binary files /dev/null and b/storage/files/3.jpg differ
diff --git a/storage/files/4.jpg b/storage/files/4.jpg
new file mode 100644
index 0000000..988ce65
Binary files /dev/null and b/storage/files/4.jpg differ
diff --git a/test/test_app.rb b/test/test_app.rb
index be61a3d..9d7c06b 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -11,7 +11,15 @@ class TestApp < Minitest::Test
def domus = Domus::Web.opts.fetch(:app)
+ # Every test starts from the shared seed baseline, so dev and the suite
+ # exercise the same Domus::Seeds path. Tests that need a clean slate call
+ # #wipe; tests that assert on counts compare deltas around the action.
def setup
+ wipe
+ Domus::Seeds.call(domus)
+ end
+
+ def wipe
domus.db[:asset_attachments].delete
domus.db[:assets].delete
domus.db[:files].delete
@@ -25,6 +33,7 @@ class TestApp < Minitest::Test
end
def test_root_lists_recent_assets_newest_first
+ wipe
now = Time.now
domus.db[:assets].insert(name: "Older asset", created_at: now - 86_400)
domus.db[:assets].insert(name: "Newer asset", created_at: now)
@@ -46,6 +55,7 @@ class TestApp < Minitest::Test
end
def test_root_empty_state
+ wipe
get "/"
assert_equal 200, last_response.status
assert_includes last_response.body, "Nothing tracked yet."
@@ -123,7 +133,7 @@ class TestApp < Minitest::Test
def test_get_file_serves_stored_image
post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
- file = domus.db[:files].first
+ file = domus.db[:files].order(:id).last
get "/files/#{file[:id]}#{file[:extension]}"
assert_equal 200, last_response.status
@@ -138,82 +148,93 @@ class TestApp < Minitest::Test
end
def test_upload_image_saves_file_and_redirects
+ before = domus.db[:files].count
post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
assert_equal 302, last_response.status
- rows = domus.db[:files].all
- assert_equal 1, rows.size
- assert_equal ".png", rows.first[:extension]
- assert_equal "fake-png-bytes", File.read(domus.file_path(rows.first))
+ assert_equal before + 1, domus.db[:files].count
+ row = domus.db[:files].order(:id).last
+ assert_equal ".png", row[:extension]
+ assert_equal "fake-png-bytes", File.read(domus.file_path(row))
end
def test_upload_without_file_is_rejected
+ before = domus.db[:files].count
post "/files", {}
assert_equal 422, last_response.status
- assert_equal 0, domus.db[:files].count
+ assert_equal before, domus.db[:files].count
end
def test_upload_rejects_non_image
+ before = domus.db[:files].count
post "/files", "file" => upload("notes.txt", "text/plain", "hello")
assert_equal 422, last_response.status
- assert_equal 0, domus.db[:files].count
+ assert_equal before, domus.db[:files].count
end
def test_upload_rejects_unsupported_extension
+ before = domus.db[:files].count
post "/files", "file" => upload("sketch.svg", "image/svg+xml", "<svg/>")
assert_equal 422, last_response.status
- assert_equal 0, domus.db[:files].count
+ assert_equal before, domus.db[:files].count
end
def test_upload_rejects_oversized_file
+ before = domus.db[:files].count
oversized = "x" * (Domus::Web::MAX_UPLOAD_BYTES + 1)
post "/files", "file" => upload("huge.png", "image/png", oversized)
assert_equal 422, last_response.status
- assert_equal 0, domus.db[:files].count
+ assert_equal before, domus.db[:files].count
end
def test_upload_with_asset_name_creates_asset_and_attachment
+ before = domus.db[:assets].count
post "/files", "file" => upload("photo.png", "image/png", "bytes"), "asset_names[]" => "Laptop"
assert_equal 302, last_response.status
- asset = domus.db[:assets].first
- refute_nil asset
+ assert_equal before + 1, domus.db[:assets].count
+ asset = domus.db[:assets].order(:id).last
assert_equal "Laptop", asset[:name]
- file = domus.db[:files].first
- attachment = domus.db[:asset_attachments].first
+ file = domus.db[:files].order(:id).last
+ attachment = domus.db[:asset_attachments].where(asset_id: asset[:id]).first
refute_nil attachment
- assert_equal asset[:id], attachment[:asset_id]
assert_equal file[:id], attachment[:file_id]
end
def test_upload_with_multiple_asset_names_creates_all
+ assets_before = domus.db[:assets].count
+ attachments_before = domus.db[:asset_attachments].count
post "/files", "file" => upload("photo.png", "image/png", "bytes"),
"asset_names[]" => ["Camera", "Laptop"]
assert_equal 302, last_response.status
- assert_equal 2, domus.db[:assets].count
- assert_equal 2, domus.db[:asset_attachments].count
+ assert_equal assets_before + 2, domus.db[:assets].count
+ assert_equal attachments_before + 2, domus.db[:asset_attachments].count
end
def test_upload_with_blank_asset_names_ignored
+ assets_before = domus.db[:assets].count
+ attachments_before = domus.db[:asset_attachments].count
post "/files", "file" => upload("photo.png", "image/png", "bytes"),
"asset_names[]" => ["", " "]
assert_equal 302, last_response.status
- assert_equal 0, domus.db[:assets].count
- assert_equal 0, domus.db[:asset_attachments].count
+ assert_equal assets_before, domus.db[:assets].count
+ assert_equal attachments_before, domus.db[:asset_attachments].count
end
def test_upload_without_asset_names_creates_no_assets
+ assets_before = domus.db[:assets].count
+ attachments_before = domus.db[:asset_attachments].count
post "/files", "file" => upload("photo.png", "image/png", "bytes")
assert_equal 302, last_response.status
- assert_equal 0, domus.db[:assets].count
- assert_equal 0, domus.db[:asset_attachments].count
+ assert_equal assets_before, domus.db[:assets].count
+ assert_equal attachments_before, domus.db[:asset_attachments].count
end
private
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2485587..f0701ed 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -6,6 +6,7 @@ require "pathname"
require_relative "../lib/app"
require_relative "../lib/web"
+require_relative "../lib/seeds"
storage = Dir.mktmpdir("domus-test")
at_exit { FileUtils.rm_rf(storage) }