Replace dataset access with Sequel models, rename files to uploads
Moves all database access from raw datasets (db[:assets], db[:files],
db[:asset_attachments]) to Sequel model classes (Asset, Upload,
Document). Renames the files table, routes, and storage directory to
uploads throughout. The sole lookup method moves from a dataset extension
to a model plugin.
diff --git a/.gitignore b/.gitignore
index bb3b539..1c1cfe5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-/db/*.db
+*.db
.bundle/
/vendor/
diff --git a/config.ru b/config.ru
index 1031a7d..8f5de56 100644
--- a/config.ru
+++ b/config.ru
@@ -1,5 +1,5 @@
-# Put lib/ on the load path so plain requires and Sequel's extension loader
-# (db.extension(:sole) -> require "sequel/extensions/sole") resolve.
+# Put lib/ on the load path so plain requires and Sequel's plugin loader
+# (Sequel::Model.plugin :sole -> require "sequel/plugins/sole") resolve.
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
require "domus/app"
@@ -8,6 +8,11 @@ require "sequel/extensions/migration"
app = Domus::App.new
Sequel::Migrator.run(app.db, "db/migrate") unless Dir.empty?("db/migrate")
+
+# Models reflect on their tables at class-load time, so they must be required
+# only after migrations have run.
+require "domus/models"
+
Domus::Web.opts[:app] = app
run Domus::Web
diff --git a/db/migrate/006_rename_files_to_uploads.rb b/db/migrate/006_rename_files_to_uploads.rb
new file mode 100644
index 0000000..a30c0cf
--- /dev/null
+++ b/db/migrate/006_rename_files_to_uploads.rb
@@ -0,0 +1,6 @@
+Sequel.migration do
+ change do
+ rename_table :files, :uploads
+ rename_column :asset_attachments, :file_id, :upload_id
+ end
+end
diff --git a/db/migrate/007_asset_attachments_created_at_default.rb b/db/migrate/007_asset_attachments_created_at_default.rb
new file mode 100644
index 0000000..7a3e6a9
--- /dev/null
+++ b/db/migrate/007_asset_attachments_created_at_default.rb
@@ -0,0 +1,20 @@
+Sequel.migration do
+ # The many_to_many association's add_upload inserts into the join table
+ # without setting created_at. Give the column a default so the bare insert
+ # still populates it and the NOT NULL constraint holds. The timestamp orders
+ # an asset's photos (oldest first); upload_id breaks ties within the
+ # one-second resolution of CURRENT_TIMESTAMP.
+ #
+ # set_column_default is irreversible under `change`, so up/down are explicit.
+ up do
+ alter_table(:asset_attachments) do
+ set_column_default :created_at, Sequel::CURRENT_TIMESTAMP
+ end
+ end
+
+ down do
+ alter_table(:asset_attachments) do
+ set_column_default :created_at, nil
+ end
+ end
+end
diff --git a/lib/domus/app.rb b/lib/domus/app.rb
index bcc3ee7..c5d2b35 100644
--- a/lib/domus/app.rb
+++ b/lib/domus/app.rb
@@ -11,15 +11,15 @@ module Domus
def initialize(config = Domus.config)
@config = config
@db = Sequel.sqlite(config.database_url)
- @db.extension(:sole)
+ Sequel::Model.db = @db
end
# : (Hash[Symbol, untyped]) -> Pathname
def file_path(record)
- files_root / "#{record[:id]}#{record[:extension]}"
+ uploads_root / "#{record[:id]}#{record[:extension]}"
end
# : () -> Pathname
- def files_root = config.storage_path / "files"
+ def uploads_root = config.storage_path / "uploads"
end
end
diff --git a/lib/domus/models.rb b/lib/domus/models.rb
index ab6bd41..1a7513a 100644
--- a/lib/domus/models.rb
+++ b/lib/domus/models.rb
@@ -1,6 +1,41 @@
# rbs_inline: enabled
+require "sequel"
+
+Sequel::Model.plugin :sole
+
module Domus
+ class Asset < Sequel::Model
+ plugin :timestamps, update_on_create: false
+ plugin :validation_helpers
+ many_to_many :uploads,
+ join_table: :asset_attachments,
+ left_key: :asset_id, right_key: :upload_id,
+ order: [Sequel[:asset_attachments][:created_at], Sequel[:asset_attachments][:upload_id]]
+
+ def validate
+ super
+ validates_presence :name
+ end
+ end
+
+ class Upload < Sequel::Model(:uploads)
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif].freeze
+
+ plugin :timestamps, update_on_create: false
+ plugin :validation_helpers
+ many_to_many :assets,
+ join_table: :asset_attachments,
+ left_key: :upload_id, right_key: :asset_id
+
+ def validate
+ super
+ validates_presence :extension
+ validates_includes IMAGE_EXTENSIONS, :extension
+ end
+ end
+
class Document < Sequel::Model
+ plugin :timestamps, update_on_create: false
end
end
diff --git a/lib/domus/seeds.rb b/lib/domus/seeds.rb
index 3a55285..088e9f4 100644
--- a/lib/domus/seeds.rb
+++ b/lib/domus/seeds.rb
@@ -90,15 +90,14 @@ module Domus
ASSETS.flat_map(&:photos).uniq.each(&:fetch)
db.transaction do
- now = Time.now
- ASSETS.each do |asset|
- asset_id = db[:assets].insert(name: asset.name, description: asset.description, created_at: now)
- asset.photos.each do |photo|
- file_id = db[:files].insert(extension: ".jpg", created_at: now)
- dest = app.file_path(id: file_id, extension: ".jpg")
+ ASSETS.each do |seed_asset|
+ asset_record = Domus::Asset.create(name: seed_asset.name, description: seed_asset.description)
+ seed_asset.photos.each do |photo|
+ upload = Domus::Upload.create(extension: ".jpg")
+ dest = app.file_path(id: upload.id, extension: ".jpg")
FileUtils.mkdir_p(dest.dirname)
FileUtils.cp(photo.cache_path, dest)
- db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+ asset_record.add_upload(upload)
end
end
end
diff --git a/lib/domus/views/asset.rb b/lib/domus/views/asset.rb
index ab4acd5..5da4fe7 100644
--- a/lib/domus/views/asset.rb
+++ b/lib/domus/views/asset.rb
@@ -15,6 +15,7 @@ module Domus
class Asset < Phlex::HTML
include Icons
+ # : (asset: Asset, ?images: Array[Upload]) -> void
def initialize(asset:, images: [])
@asset = asset
@images = images
@@ -99,9 +100,9 @@ module Domus
section(class: "panel") do
div(class: "panel-h") { h3(class: "sub") { plain "Photos" } }
div(class: "photos") do
- @images.each do |file|
+ @images.each do |upload|
div(class: "shot") do
- img(src: "/files/#{file[:id]}#{file[:extension]}", alt: "Photo of #{@asset[:name]}", loading: "lazy")
+ img(src: "/uploads/#{upload[:id]}#{upload[:extension]}", alt: "Photo of #{@asset[:name]}", loading: "lazy")
end
end
button(type: "button", class: "addphoto") { icon("camera"); plain "add" }
diff --git a/lib/domus/views/home.rb b/lib/domus/views/home.rb
index 25dc7fe..c4b7a85 100644
--- a/lib/domus/views/home.rb
+++ b/lib/domus/views/home.rb
@@ -15,7 +15,7 @@ module Domus
class Home < Phlex::HTML
include Icons
- # : (assets: Array[Hash[Symbol, untyped]], total: Integer) -> void
+ # : (assets: Array[Asset], total: Integer) -> void
def initialize(assets:, total:)
@assets = assets
@total = total
diff --git a/lib/domus/web.rb b/lib/domus/web.rb
index 2cc81b5..5a23542 100644
--- a/lib/domus/web.rb
+++ b/lib/domus/web.rb
@@ -23,11 +23,11 @@ module Domus
class Web < Roda
plugin :public
- # Serve stored uploads (storage/files/{id}{ext}) at /files/ straight off
- # disk. The :static plugin needs a concrete root at load time, so it reads
- # the shared Domus.config (the same instance App uses). Uploads are
+ # Serve stored uploads (storage/uploads/{id}{ext}) at /uploads/ straight
+ # off disk. The :static plugin needs a concrete root at load time, so it
+ # reads the shared Domus.config (the same instance App uses). Uploads are
# immutable once stored, so they cache indefinitely.
- plugin :static, ["/files/"],
+ plugin :static, ["/uploads/"],
root: Domus.config.storage_path.to_s,
cache_control: "private, max-age=31536000, immutable"
@@ -40,12 +40,14 @@ module Domus
when Sequel::NoMatchingRow
response.status = 404
"Not found."
+ when Sequel::ValidationFailed
+ response.status = 422
+ e.message
else
raise e
end
end
- IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .webp .heic .heif].freeze
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
route do |r|
@@ -53,21 +55,21 @@ module Domus
r.root do
r.get do
- assets = db[:assets].order(Sequel.desc(:created_at), Sequel.desc(:id)).limit(12).all
- Views::Home.new(assets:, total: db[:assets].count).call
+ assets = Asset.order(Sequel.desc(:created_at), Sequel.desc(:id)).limit(12).all
+ Views::Home.new(assets:, total: Asset.count).call
end
end
- # POST /files has no CSRF token check. Domus authenticates via the
+ # POST /uploads has no CSRF token check. Domus authenticates via the
# reverse proxy's trusted X-Forwarded-User header (see
# lib/middleware/auth.rb), not cookie sessions, so there's no ambient
# credential a forged cross-site POST could ride on. If this app ever
# adopts cookie-based sessions, load the Roda :route_csrf plugin and
# verify the token here before accepting the upload.
- # GET /files/:filename (the stored uploads) is served straight off disk
- # by the :static middleware configured above. POST /files stays on the
- # route.
- r.on "files" do
+ # GET /uploads/:filename (the stored uploads) is served straight off
+ # disk by the :static middleware configured above. POST /uploads stays
+ # on the route.
+ r.on "uploads" do
r.post do
save_file(r.params)
r.redirect "/"
@@ -79,8 +81,8 @@ module Domus
r.get do
# .sole raises Sequel::NoMatchingRow when the id is unknown; the
# error_handler above turns that into a 404.
- asset = db[:assets].where(id:).sole
- Views::Asset.new(asset:, images: asset_images(id)).call
+ asset = Asset.where(id:).sole
+ Views::Asset.new(asset:, images: asset.uploads).call
end
end
end
@@ -93,16 +95,6 @@ module Domus
# : () -> Sequel::Database
def db = app.db
- # Files attached to an asset, oldest first, as {id:, extension:} rows.
- def asset_images(asset_id)
- db[:asset_attachments]
- .where(asset_id:)
- .join(:files, id: :file_id)
- .order(Sequel[:asset_attachments][:created_at], Sequel[:files][:id])
- .select(Sequel[:files][:id], Sequel[:files][:extension])
- .all
- end
-
# Persists an uploaded image, raising ClientError when the upload is
# rejected so the error_handler plugin can render the right status.
# : (Hash[String, untyped]) -> void
@@ -114,23 +106,17 @@ module Domus
# The browser-supplied type is spoofable, so also require a known
# image extension before we trust and store the file.
ext = ::File.extname(upload[:filename].to_s).downcase
- raise ClientError, "That image format isn't supported." unless IMAGE_EXTENSIONS.include?(ext)
+ raise ClientError, "That image format isn't supported." unless Upload::IMAGE_EXTENSIONS.include?(ext)
raise ClientError, "That image is too large (25 MB max)." if upload[:tempfile].size > MAX_UPLOAD_BYTES
asset_names = Array(params["asset_names"]).flatten.map(&:strip).reject(&:empty?)
db.transaction do
- file_id = db[:files].insert(extension: ext, created_at: Time.now)
-
- dest = app.file_path(id: file_id, extension: ext)
+ upload_record = Upload.create(extension: ext)
+ dest = app.file_path(id: upload_record.id, extension: ext)
FileUtils.mkdir_p(::File.dirname(dest))
FileUtils.cp(upload[:tempfile].path, dest)
-
- now = Time.now
- asset_names.each do |name|
- asset_id = db[:assets].insert(name: name, created_at: now)
- db[:asset_attachments].insert(asset_id: asset_id, file_id: file_id, created_at: now)
- end
+ asset_names.each { |name| Asset.create(name:).add_upload(upload_record) }
end
end
end
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
deleted file mode 100644
index e64c25d..0000000
--- a/lib/sequel/extensions/sole.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-require "sequel"
-
-module Sequel
- # The sole extension adds a +sole+ dataset method that returns the single
- # matching row, raising if zero or more than one row matches. It mirrors the
- # ketchup plugin of the same name, but as a dataset extension so it works on
- # Domus's raw datasets without a Sequel::Model layer.
- #
- # db[:assets].where(id: 1).sole # => {id: 1, ...}
- # db[:assets].where(id: 0).sole # raises Sequel::NoMatchingRow
- # db[:assets].sole # raises Sequel::Sole::TooManyRows (if > 1)
- #
- # Load it onto every dataset of a database with:
- #
- # db.extension(:sole)
- module Sole
- class TooManyRows < Sequel::Error; end
-
- module DatasetMethods
- def sole
- results = limit(2).all
- raise Sequel::NoMatchingRow.new(self) if results.empty?
- raise TooManyRows, "expected 1 row, got multiple" if results.length > 1
-
- results.first
- end
- end
- end
-
- Dataset.register_extension(:sole, Sole::DatasetMethods)
-end
diff --git a/lib/sequel/plugins/sole.rb b/lib/sequel/plugins/sole.rb
new file mode 100644
index 0000000..59c8f47
--- /dev/null
+++ b/lib/sequel/plugins/sole.rb
@@ -0,0 +1,19 @@
+require "sequel"
+
+module Sequel
+ module Plugins
+ module Sole
+ class TooManyRows < Sequel::Error; end
+
+ module DatasetMethods
+ def sole
+ results = limit(2).all
+ raise Sequel::NoMatchingRow.new(self) if results.empty?
+ raise TooManyRows, "expected 1 row, got multiple" if results.length > 1
+
+ results.first
+ end
+ end
+ end
+ end
+end
diff --git a/sig/generated/domus/app.rbs b/sig/generated/domus/app.rbs
index 4ae772b..d0bd611 100644
--- a/sig/generated/domus/app.rbs
+++ b/sig/generated/domus/app.rbs
@@ -13,6 +13,6 @@ module Domus
def file_path: (untyped record) -> untyped
# : () -> Pathname
- def files_root: () -> untyped
+ def uploads_root: () -> untyped
end
end
diff --git a/sig/generated/domus/models.rbs b/sig/generated/domus/models.rbs
index 67a6b93..74c2337 100644
--- a/sig/generated/domus/models.rbs
+++ b/sig/generated/domus/models.rbs
@@ -1,6 +1,16 @@
# Generated from lib/domus/models.rb with RBS::Inline
module Domus
+ class Asset < Sequel::Model
+ def validate: () -> untyped
+ end
+
+ class Upload
+ IMAGE_EXTENSIONS: untyped
+
+ def validate: () -> untyped
+ end
+
class Document < Sequel::Model
end
end
diff --git a/sig/generated/domus/views/home.rbs b/sig/generated/domus/views/home.rbs
index c11802e..cb1c2c7 100644
--- a/sig/generated/domus/views/home.rbs
+++ b/sig/generated/domus/views/home.rbs
@@ -9,7 +9,7 @@ module Domus
class Home < Phlex::HTML
include Icons
- # : (assets: Array[Hash[Symbol, untyped]], total: Integer) -> void
+ # : (assets: Array[Asset], total: Integer) -> void
def initialize: (assets: untyped, total: untyped) -> untyped
def view_template: () -> untyped
diff --git a/sig/generated/domus/web.rbs b/sig/generated/domus/web.rbs
index 44789ab..cc235a7 100644
--- a/sig/generated/domus/web.rbs
+++ b/sig/generated/domus/web.rbs
@@ -11,8 +11,6 @@ module Domus
end
class Web < Roda
- IMAGE_EXTENSIONS: untyped
-
MAX_UPLOAD_BYTES: untyped
private
@@ -23,9 +21,6 @@ module Domus
# : () -> Sequel::Database
def db: () -> untyped
- # Files attached to an asset, oldest first, as {id:, extension:} rows.
- def asset_images: (untyped asset_id) -> untyped
-
# Persists an uploaded image, raising ClientError when the upload is
# rejected so the error_handler plugin can render the right status.
# : (Hash[String, untyped]) -> void
diff --git a/sig/shim.rbs b/sig/shim.rbs
index 346554c..aa34f60 100644
--- a/sig/shim.rbs
+++ b/sig/shim.rbs
@@ -4,6 +4,8 @@
module Sequel
def self.sqlite: (String) -> Sequel::Database
def self.desc: (Symbol) -> untyped
+ def self.[]: (Symbol) -> untyped
+ CURRENT_TIMESTAMP: String
class Database
def []: (Symbol) -> Sequel::Dataset
@@ -16,9 +18,43 @@ module Sequel
def all: () -> Array[Hash[Symbol, untyped]]
def count: () -> Integer
def insert: (Hash[Symbol, untyped]) -> Integer
+ def delete: () -> Integer
+ def where: (*untyped) -> Sequel::Dataset
end
class Model
+ def self.plugin: (Symbol, **untyped) -> void
+ def self.count: () -> Integer
+ def self.order: (*untyped) -> Sequel::Dataset
+ def self.where: (*untyped) -> Sequel::Dataset
+ def self.create: (**untyped) -> instance
+ def self.dataset: () -> Sequel::Dataset
+ def self.many_to_many: (Symbol, **untyped) -> void
+ def self.validates_presence: (Symbol) -> void
+ def self.validates_includes: (Array[String], Symbol) -> void
+ def self.sole: () -> instance
+ def self.db=: (Database) -> void
+ def self.inherited: (Class) -> void
+ def add_upload: (untyped) -> void
+ def uploads: () -> Array[untyped]
+ def assets: () -> Array[untyped]
+ def id: () -> Integer
+ def name: () -> String
+ def extension: () -> String
+ def description: () -> String
+ def valid?: () -> bool
+ def save: () -> untyped
+ end
+
+ def self.Model: (Symbol) -> class
+
+ class Error
+ end
+
+ class ValidationFailed < Error
+ end
+
+ class NoMatchingRow < Error
end
end
@@ -34,6 +70,38 @@ class Roda
end
module Domus
+ class Asset < Sequel::Model
+ def self.plugin: (Symbol, **untyped) -> void
+ def self.many_to_many: (Symbol, **untyped) -> void
+ def validates_presence: (Symbol) -> void
+ def self.create: (**untyped) -> instance
+ def valid?: () -> bool
+ def save: () -> untyped
+ def uploads: () -> Array[untyped]
+ def assets: () -> Array[untyped]
+ def add_upload: (untyped) -> void
+ def id: () -> Integer
+ def name: () -> String
+ def extension: () -> String
+ def description: () -> String
+ def created_at: () -> untyped
+ end
+
+ class Upload < Sequel::Model
+ def self.plugin: (Symbol, **untyped) -> void
+ def self.many_to_many: (Symbol, **untyped) -> void
+ def validates_presence: (Symbol) -> void
+ def validates_includes: (Array[String], Symbol) -> void
+ def self.create: (**untyped) -> instance
+ def valid?: () -> bool
+ def save: () -> untyped
+ def uploads: () -> Array[untyped]
+ def assets: () -> Array[untyped]
+ def id: () -> Integer
+ def extension: () -> String
+ def created_at: () -> untyped
+ end
+
module Views
module Icons
def raw: (String) -> void
diff --git a/test/test_app.rb b/test/test_app.rb
index 9d7c06b..5695fe3 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -3,6 +3,7 @@ require "rack/test"
require "tempfile"
class TestApp < Minitest::Test
+ include Domus
include Rack::Test::Methods
def app
@@ -21,8 +22,8 @@ class TestApp < Minitest::Test
def wipe
domus.db[:asset_attachments].delete
- domus.db[:assets].delete
- domus.db[:files].delete
+ Asset.dataset.delete
+ Upload.dataset.delete
end
def test_root_renders_home
@@ -34,9 +35,8 @@ class TestApp < Minitest::Test
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)
+ Asset.create(name: "Older asset")
+ Asset.create(name: "Newer asset")
get "/"
assert_equal 200, last_response.status
@@ -47,11 +47,11 @@ class TestApp < Minitest::Test
end
def test_root_links_each_asset_to_its_detail_page
- id = domus.db[:assets].insert(name: "Laptop", created_at: Time.now)
+ asset = Asset.create(name: "Laptop")
get "/"
assert_equal 200, last_response.status
- assert_includes last_response.body, %(href="/assets/#{id}")
+ assert_includes last_response.body, %(href="/assets/#{asset.id}")
end
def test_root_empty_state
@@ -73,9 +73,9 @@ class TestApp < Minitest::Test
end
def test_asset_detail_renders_title
- id = domus.db[:assets].insert(name: "Bosch 800 dishwasher", created_at: Time.now)
+ asset = Asset.create(name: "Bosch 800 dishwasher")
- get "/assets/#{id}"
+ get "/assets/#{asset.id}"
assert_equal 200, last_response.status
body = last_response.body
assert_includes body, "Bosch 800 dishwasher"
@@ -83,13 +83,12 @@ class TestApp < Minitest::Test
end
def test_asset_detail_renders_description
- id = domus.db[:assets].insert(
+ asset = Asset.create(
name: "Bosch 800 dishwasher",
- description: "Stainless interior, third rack.\n\nReplaces the GE that flooded.",
- created_at: Time.now
+ description: "Stainless interior, third rack.\n\nReplaces the GE that flooded."
)
- get "/assets/#{id}"
+ get "/assets/#{asset.id}"
assert_equal 200, last_response.status
body = last_response.body
assert_includes body, "Stainless interior, third rack."
@@ -97,9 +96,9 @@ class TestApp < Minitest::Test
end
def test_asset_detail_omits_description_when_absent
- id = domus.db[:assets].insert(name: "Untitled", created_at: Time.now)
+ asset = Asset.create(name: "Untitled")
- get "/assets/#{id}"
+ get "/assets/#{asset.id}"
assert_equal 200, last_response.status
refute_includes last_response.body, 'class="desc"'
end
@@ -110,20 +109,19 @@ class TestApp < Minitest::Test
end
def test_asset_detail_renders_attached_images
- now = Time.now
- asset_id = domus.db[:assets].insert(name: "Dishwasher", created_at: now)
- file_id = domus.db[:files].insert(extension: ".png", created_at: now)
- domus.db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+ asset = Asset.create(name: "Dishwasher")
+ upload = Upload.create(extension: ".png")
+ asset.add_upload(upload)
- get "/assets/#{asset_id}"
+ get "/assets/#{asset.id}"
assert_equal 200, last_response.status
- assert_includes last_response.body, %(src="/files/#{file_id}.png")
+ assert_includes last_response.body, %(src="/uploads/#{upload.id}.png")
end
def test_asset_detail_without_images_shows_photos_add_affordance
- id = domus.db[:assets].insert(name: "Bare", created_at: Time.now)
+ asset = Asset.create(name: "Bare")
- get "/assets/#{id}"
+ get "/assets/#{asset.id}"
assert_equal 200, last_response.status
# The photos section always renders (with the add affordance); there
# just aren't any <img> tiles when nothing is attached.
@@ -132,10 +130,10 @@ class TestApp < Minitest::Test
end
def test_get_file_serves_stored_image
- post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
- file = domus.db[:files].order(:id).last
+ post "/uploads", "file" => upload("photo.png", "image/png", "fake-png-bytes")
+ upload_record = Upload.order(:id).last
- get "/files/#{file[:id]}#{file[:extension]}"
+ get "/uploads/#{upload_record.id}#{upload_record.extension}"
assert_equal 200, last_response.status
assert_equal "image/png", last_response.headers["Content-Type"]
assert_includes last_response.headers["Cache-Control"].to_s, "immutable"
@@ -148,93 +146,122 @@ 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")
+ before = Upload.count
+ post "/uploads", "file" => upload("photo.png", "image/png", "fake-png-bytes")
assert_equal 302, last_response.status
- 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))
+ assert_equal before + 1, Upload.count
+ upload_record = Upload.order(:id).last
+ assert_equal ".png", upload_record.extension
+ assert_equal "fake-png-bytes", File.read(domus.file_path(id: upload_record.id, extension: upload_record.extension))
end
def test_upload_without_file_is_rejected
- before = domus.db[:files].count
- post "/files", {}
+ before = Upload.count
+ post "/uploads", {}
assert_equal 422, last_response.status
- assert_equal before, domus.db[:files].count
+ assert_equal before, Upload.count
end
def test_upload_rejects_non_image
- before = domus.db[:files].count
- post "/files", "file" => upload("notes.txt", "text/plain", "hello")
+ before = Upload.count
+ post "/uploads", "file" => upload("notes.txt", "text/plain", "hello")
assert_equal 422, last_response.status
- assert_equal before, domus.db[:files].count
+ assert_equal before, Upload.count
end
def test_upload_rejects_unsupported_extension
- before = domus.db[:files].count
- post "/files", "file" => upload("sketch.svg", "image/svg+xml", "<svg/>")
+ before = Upload.count
+ post "/uploads", "file" => upload("sketch.svg", "image/svg+xml", "<svg/>")
assert_equal 422, last_response.status
- assert_equal before, domus.db[:files].count
+ assert_equal before, Upload.count
end
def test_upload_rejects_oversized_file
- before = domus.db[:files].count
+ before = Upload.count
oversized = "x" * (Domus::Web::MAX_UPLOAD_BYTES + 1)
- post "/files", "file" => upload("huge.png", "image/png", oversized)
+ post "/uploads", "file" => upload("huge.png", "image/png", oversized)
assert_equal 422, last_response.status
- assert_equal before, domus.db[:files].count
+ assert_equal before, Upload.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"
+ before = Asset.count
+ post "/uploads", "file" => upload("photo.png", "image/png", "bytes"), "asset_names[]" => "Laptop"
assert_equal 302, last_response.status
- assert_equal before + 1, domus.db[:assets].count
- asset = domus.db[:assets].order(:id).last
- assert_equal "Laptop", asset[:name]
- file = domus.db[:files].order(:id).last
- attachment = domus.db[:asset_attachments].where(asset_id: asset[:id]).first
- refute_nil attachment
- assert_equal file[:id], attachment[:file_id]
+ assert_equal before + 1, Asset.count
+ asset = Asset.order(:id).last
+ assert_equal "Laptop", asset.name
+ upload_record = Upload.order(:id).last
+ assert_includes asset.uploads, upload_record
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"),
+ assets_before = Asset.count
+ post "/uploads", "file" => upload("photo.png", "image/png", "bytes"),
"asset_names[]" => ["Camera", "Laptop"]
assert_equal 302, last_response.status
- assert_equal assets_before + 2, domus.db[:assets].count
- assert_equal attachments_before + 2, domus.db[:asset_attachments].count
+ assert_equal assets_before + 2, Asset.count
+ assert_equal 2, Upload.order(:id).last.assets.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"),
+ assets_before = Asset.count
+ post "/uploads", "file" => upload("photo.png", "image/png", "bytes"),
"asset_names[]" => ["", " "]
assert_equal 302, last_response.status
- assert_equal assets_before, domus.db[:assets].count
- assert_equal attachments_before, domus.db[:asset_attachments].count
+ assert_equal assets_before, Asset.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")
+ assets_before = Asset.count
+ post "/uploads", "file" => upload("photo.png", "image/png", "bytes")
assert_equal 302, last_response.status
- assert_equal assets_before, domus.db[:assets].count
- assert_equal attachments_before, domus.db[:asset_attachments].count
+ assert_equal assets_before, Asset.count
+ end
+
+ def test_asset_validates_presence_of_name
+ asset = Asset.new(name: "")
+ refute asset.valid?
+ assert_raises(Sequel::ValidationFailed) { asset.save }
+ end
+
+ def test_upload_validates_presence_of_extension
+ upload_record = Upload.new(extension: "")
+ refute upload_record.valid?
+ assert_raises(Sequel::ValidationFailed) { upload_record.save }
+ end
+
+ def test_upload_validates_image_extension
+ upload_record = Upload.new(extension: ".pdf")
+ refute upload_record.valid?
+ assert_raises(Sequel::ValidationFailed) { upload_record.save }
+ end
+
+ def test_asset_uploads_returns_uploads_oldest_first
+ asset = Asset.create(name: "Dishwasher")
+ oldest = Upload.create(extension: ".png")
+ newest = Upload.create(extension: ".jpg")
+ asset.add_upload(oldest)
+ sleep 0.01
+ asset.add_upload(newest)
+
+ uploads = asset.uploads
+ assert_equal 2, uploads.length
+ assert_equal oldest.id, uploads.first.id
+ end
+
+ def test_validation_failed_returns_422
+ post "/uploads", "file" => upload("notes.txt", "text/plain", "hello")
+ assert_equal 422, last_response.status
end
private
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2437373..a1e5d8b 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -13,10 +13,16 @@ at_exit { FileUtils.rm_rf(storage) }
# a temp-dir config before requiring it.
Domus.config = Domus::Config.new(database_url: ":memory:", storage_path: Pathname(storage))
-require "domus/web"
-require "domus/seeds"
-
app = Domus::App.new
migrate_dir = File.expand_path("../db/migrate", __dir__)
Sequel::Migrator.run(app.db, migrate_dir) unless Dir.empty?(migrate_dir)
+
+# Models reflect on their tables at class-load time, so they must be required
+# only after migrations have run. App#initialize sets Sequel::Model.db, so
+# models can be defined once the app exists.
+require "domus/models"
+
+require "domus/web"
+require "domus/seeds"
+
Domus::Web.opts[:app] = app
diff --git a/test/test_sole.rb b/test/test_sole.rb
index e429357..c91c9da 100644
--- a/test/test_sole.rb
+++ b/test/test_sole.rb
@@ -1,28 +1,23 @@
require_relative "test_helper"
class TestSole < Minitest::Test
- def db = Domus::Web.opts.fetch(:app).db
-
- def setup
- db[:asset_attachments].delete
- db[:assets].delete
- end
+ include Domus
def test_sole_returns_the_single_matching_row
- id = db[:assets].insert(name: "Only", created_at: Time.now)
- assert_equal "Only", db[:assets].where(id:).sole[:name]
+ asset = Asset.create(name: "Only")
+ assert_equal "Only", Asset.where(id: asset.id).sole.name
end
def test_sole_raises_when_no_rows_match
assert_raises(Sequel::NoMatchingRow) do
- db[:assets].where(id: 999_999).sole
+ Asset.where(id: 999_999).sole
end
end
def test_sole_raises_when_multiple_rows_match
- db[:assets].insert(name: "A", created_at: Time.now)
- db[:assets].insert(name: "B", created_at: Time.now)
+ Asset.create(name: "A")
+ Asset.create(name: "B")
- assert_raises(Sequel::Sole::TooManyRows) { db[:assets].sole }
+ assert_raises(Sequel::Plugins::Sole::TooManyRows) { Asset.dataset.sole }
end
end