Phase 3: move service layer, serializers and DTOs into WarpEngine + dummy-app test suite

- all catalog services (update/software/highlighted/builds/file/file-manager/
  download/image + SoftwareResponseBuilder), the SoftwareUpdater platform
  services and their concerns, Blueprinter serializers (incl. TimestampFields)
  and the 4 DTOs now live in the engine under WarpEngine::
- constantize dispatch strings use absolute names
  (WarpEngine::SoftwareUpdater::<Platform>Service)
- container paths read from WarpEngine.config everywhere (FileService,
  DownloadService, FileManagerService, ArchiveExtraction, ReleaseSerializer
  path rewriting); FileManagerService base path is now lazy
- engine requires blueprinter itself; gemspec declares blueprinter + rubyzip
- engine test suite: spec/dummy app (mysql warp_engine_test, catalog-only
  schema), rails_helper with engine-local factories; catalog model/service
  specs and factories moved from the host
- host suite keeps TTG specs and loads catalog factories from the engine

Verified: engine suite 40 green, host suite 14 green, /api/software and
/api/builds byte-identical to baselines, admin OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:03:58 +02:00
co-authored by Claude Fable 5
parent 7abfb89bca
commit ca25f72c76
69 changed files with 1491 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
require_relative "config/application"
Rails.application.load_tasks
+4
View File
@@ -0,0 +1,4 @@
require_relative "config/environment"
run Rails.application
Rails.application.load_server
+23
View File
@@ -0,0 +1,23 @@
require_relative "boot"
require "rails"
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "action_dispatch/railtie"
Bundler.require(*Rails.groups)
require "warp_engine"
module Dummy
class Application < Rails::Application
config.load_defaults 8.0
config.eager_load = false
config.time_zone = "UTC"
config.active_record.default_timezone = :utc
config.hosts.clear
end
end
+3
View File
@@ -0,0 +1,3 @@
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)
require "bundler/setup"
+8
View File
@@ -0,0 +1,8 @@
test:
adapter: mysql2
encoding: utf8mb4
username: <%= ENV.fetch("DB_USER", "root") %>
password: <%= ENV.fetch("DB_PASSWORD", "") %>
host: <%= ENV.fetch("DB_HOST", "mysql") %>
port: <%= ENV.fetch("DB_PORT", "3306") %>
database: warp_engine_test
+3
View File
@@ -0,0 +1,3 @@
require_relative "application"
Rails.application.initialize!
+7
View File
@@ -0,0 +1,7 @@
Rails.application.configure do
config.cache_classes = true
config.consider_all_requests_local = true
config.action_dispatch.show_exceptions = :rescuable
config.active_support.deprecation = :stderr
config.log_level = :warn
end
+3
View File
@@ -0,0 +1,3 @@
Rails.application.routes.draw do
mount WarpEngine::Engine => "/"
end
+108
View File
@@ -0,0 +1,108 @@
# A katalógus-táblák a host schema.rb-vel megegyező definícióval.
ActiveRecord::Schema[8.1].define(version: 1) do
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.string "file_path", null: false
t.string "ip_address"
t.string "referer", limit: 500
t.bigint "release_id", unsigned: true
t.datetime "updated_at", precision: 3
t.string "user_agent", limit: 500
t.index ["deleted_at"], name: "idx_downloads_deleted_at"
t.index ["file_path"], name: "index_downloads_on_file_path"
t.index ["release_id"], name: "index_downloads_on_release_id"
end
create_table "external_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.string "label", limit: 128
t.bigint "software_id", unsigned: true
t.datetime "updated_at", precision: 3
t.string "url"
t.index ["deleted_at"], name: "idx_external_links_deleted_at"
t.index ["software_id"], name: "idx_external_links_software_id"
end
create_table "images", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "content_type", default: "application/octet-stream", null: false
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
t.string "filename", null: false
t.string "original_filename", null: false
t.datetime "updated_at", null: false
t.index ["deleted_at"], name: "idx_images_deleted_at"
end
create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.string "name", limit: 128
t.string "platform", limit: 128
t.integer "position", default: 0, null: false
t.datetime "updated_at", precision: 3
t.string "url"
t.index ["deleted_at"], name: "idx_platform_links_deleted_at"
t.index ["platform"], name: "idx_platform_links_platform"
end
create_table "release_assets", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "kind", limit: 32, null: false
t.string "path", null: false
t.bigint "release_id", null: false, unsigned: true
t.datetime "updated_at", precision: 3, null: false
t.index ["deleted_at"], name: "idx_release_assets_deleted_at"
t.index ["path"], name: "idx_release_assets_path"
t.index ["release_id", "kind"], name: "idx_release_assets_release_kind", unique: true
end
create_table "releases", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.bigint "software_id", unsigned: true
t.datetime "updated_at", precision: 3
t.string "version", limit: 64
t.index ["deleted_at"], name: "idx_releases_deleted_at"
t.index ["software_id"], name: "idx_releases_software_id"
end
create_table "software_images", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "image_id", null: false
t.boolean "is_default", default: false, null: false
t.integer "position", default: 0, null: false
t.bigint "software_id", null: false, unsigned: true
t.datetime "updated_at", null: false
t.index ["image_id"], name: "fk_rails_a456fbc278"
t.index ["software_id", "image_id"], name: "index_software_images_on_software_id_and_image_id", unique: true
t.index ["software_id", "position"], name: "index_software_images_on_software_id_and_position"
end
create_table "softwares", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "author"
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.text "desc"
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
t.text "story"
t.string "title"
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
end
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade
add_foreign_key "software_images", "images"
add_foreign_key "software_images", "softwares", on_delete: :cascade
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :download, class: "WarpEngine::Download" do
file_path { "/test/file.tic" }
ip_address { "127.0.0.1" }
end
end
+8
View File
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :platform_link, class: "WarpEngine::PlatformLink" do
sequence(:name) { |n| "Link #{n}" }
platform { "tic80" }
url { "https://example.com/link" }
position { 0 }
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :release, class: "WarpEngine::Release" do
software
sequence(:version) { |n| "1.0.#{n}" }
end
end
+9
View File
@@ -0,0 +1,9 @@
FactoryBot.define do
factory :software, class: "WarpEngine::Software" do
sequence(:name) { |n| "game-#{n}" }
title { "Test Game" }
author { "dev" }
platform { "tic80" }
status { "development" }
end
end
+6
View File
@@ -0,0 +1,6 @@
require "rails_helper"
RSpec.describe WarpEngine::Download, type: :model do
it { should validate_presence_of(:file_path) }
it { should belong_to(:release).optional }
end
+52
View File
@@ -0,0 +1,52 @@
require "rails_helper"
RSpec.describe WarpEngine::PlatformLink, type: :model do
describe "validations" do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:url) }
it { should validate_presence_of(:platform) }
it "rejects invalid platform" do
link = build(:platform_link, platform: "invalid")
expect(link).not_to be_valid
expect(link.errors[:platform]).to be_present
end
it "accepts valid platforms" do
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
link = build(:platform_link, platform: p)
expect(link).to be_valid
end
end
end
describe "default scope" do
it "excludes soft-deleted records" do
active = create(:platform_link)
create(:platform_link, deleted_at: Time.current)
expect(WarpEngine::PlatformLink.all).to eq([active])
end
it "orders by position" do
second = create(:platform_link, position: 2)
first = create(:platform_link, position: 1)
expect(WarpEngine::PlatformLink.all).to eq([first, second])
end
end
describe ".for_platform" do
it "returns links for given platform only" do
tic80_link = create(:platform_link, platform: "tic80")
create(:platform_link, platform: "love")
result = WarpEngine::PlatformLink.for_platform("tic80")
expect(result).to eq([tic80_link])
end
it "returns empty array for platform without links" do
expect(WarpEngine::PlatformLink.for_platform("godot")).to eq([])
end
end
end
+23
View File
@@ -0,0 +1,23 @@
require "rails_helper"
RSpec.describe WarpEngine::Release, type: :model do
it { should belong_to(:software) }
it { should have_many(:downloads) }
describe "version uniqueness" do
let(:software) { create(:software) }
it "prevents duplicate versions for same software" do
create(:release, software: software, version: "1.0.0")
dup = build(:release, software: software, version: "1.0.0")
expect(dup).not_to be_valid
end
it "allows same version across different software" do
other_sw = create(:software)
create(:release, software: software, version: "1.0.0")
other = build(:release, software: other_sw, version: "1.0.0")
expect(other).to be_valid
end
end
end
+25
View File
@@ -0,0 +1,25 @@
require "rails_helper"
RSpec.describe WarpEngine::Software, type: :model do
subject { build(:software) }
it { should validate_presence_of(:name) }
it { should validate_presence_of(:title) }
it { should validate_presence_of(:platform) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:name).case_insensitive }
# a törlést a DB-szintű ON DELETE CASCADE végzi, a modellen nincs dependent opció
it { should have_many(:releases) }
it { should have_many(:external_links) }
it { should have_many(:software_images).dependent(:destroy) }
describe "default scope" do
it "excludes soft-deleted records" do
active = create(:software)
create(:software, deleted_at: Time.current)
expect(WarpEngine::Software.all).to eq([active])
end
end
end
+31
View File
@@ -0,0 +1,31 @@
require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "dummy/config/environment"
abort("The Rails environment is running in production mode!") if Rails.env.production?
require "rspec/rails"
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
# A factory_bot_rails a dummy app gyökerében keresne; a factory-k az engine spec/ alatt élnek
FactoryBot.definition_file_paths = [ File.expand_path("factories", __dir__) ]
FactoryBot.reload
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods
end
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
+50
View File
@@ -0,0 +1,50 @@
require "rails_helper"
RSpec.describe WarpEngine::BuildsService do
describe "#index" do
subject(:result) { described_class.new.index }
it "returns all supported platforms with expected kinds" do
expect(result[:platforms]).to have_key("tic80")
expect(result[:platforms]["tic80"][:label]).to eq("TIC-80")
expect(result[:platforms]["tic80"][:kinds]).to include("cartridge", "html")
end
it "returns c64 with only cartridge" do
expect(result[:platforms]["c64"][:kinds]).to eq(["cartridge"])
end
it "returns allKinds matching WarpEngine::ReleaseAsset::KINDS" do
expect(result[:allKinds]).to eq(WarpEngine::ReleaseAsset::KINDS)
end
it "includes all supported platforms" do
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |platform|
expect(result[:platforms]).to have_key(platform)
end
end
end
describe "#show" do
let!(:software) { create(:software, name: "test-game", platform: "love") }
let!(:release) { create(:release, software: software, version: "1.0.0") }
before do
WarpEngine::ReleaseAsset.create!(release: release, kind: "html", path: "/test/html")
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64", path: "/test/win")
end
it "returns expected and actual kinds per release" do
result = described_class.new.show("test-game")
expect(result[:platform]).to eq("love")
expect(result[:expected]).to match_array(%w[html win_x64 linux_x64 mac_universal])
expect(result[:releases]["1.0.0"][:actual]).to match_array(%w[html win_x64])
expect(result[:releases]["1.0.0"][:missing]).to match_array(%w[linux_x64 mac_universal])
end
it "raises RecordNotFound for unknown software" do
expect { described_class.new.show("nonexistent") }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
@@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe WarpEngine::SoftwareHighlightedService do
describe "#index" do
it "returns nil when no highlighted software" do
create(:software, highlighted: false)
result = described_class.new.index
expect(result).to be_nil
end
it "returns highlighted software" do
sw = create(:software, highlighted: true, title: "Featured")
result = described_class.new.index
expect(result).not_to be_nil
expect(result[:software][:title]).to eq("Featured")
end
end
end
@@ -0,0 +1,78 @@
require "rails_helper"
require "zip"
RSpec.describe WarpEngine::SoftwareUpdater::Tic80Service do
let(:tmpdir) { Dir.mktmpdir }
let(:name) { "spectic" }
let(:version) { "9.9" }
let(:versioned) { "#{name}-#{version}" }
before do
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
write_zip("#{versioned}.html.zip", "index.html" => "<html></html>")
File.write(File.join(tmpdir, "#{versioned}.tic"), "TIC!")
File.write(File.join(tmpdir, "#{versioned}.lua"), <<~LUA)
-- title: Spec TIC
-- name: #{name}
-- author: RSpec
-- desc: spec fixture
-- version: #{version}
function TIC() end
LUA
end
after do
FileUtils.remove_entry(tmpdir)
WarpEngine::Software.unscoped.where(name: name).each do |sw|
WarpEngine::Release.unscoped.where(software_id: sw.id).each do |r|
WarpEngine::ReleaseAsset.unscoped.where(release_id: r.id).delete_all
r.delete
end
WarpEngine::ExternalLink.unscoped.where(software_id: sw.id).delete_all
sw.delete
end
end
def write_zip(filename, entries)
Zip::File.open(File.join(tmpdir, filename), create: true) do |zip|
entries.each do |entry_name, content|
zip.get_output_stream(entry_name) { |io| io.write(content) }
end
end
end
def asset_kinds(release)
WarpEngine::ReleaseAsset.unscoped.where(release_id: release.id).pluck(:kind)
end
it "registers the release without a docs zip" do
release = described_class.new.update(name, version)
expect(asset_kinds(release)).to match_array(%w[cartridge source html])
end
it "registers docs when the docs zip is present" do
write_zip("#{versioned}-docs.zip", "index.html" => "docs")
release = described_class.new.update(name, version)
expect(asset_kinds(release)).to include("docs")
end
it "registers binary assets when slug zips are present" do
write_zip("#{versioned}-win-x64.zip", "game.exe" => "MZ")
write_zip("#{versioned}-mac-x64.zip", "game" => "bin")
release = described_class.new.update(name, version)
expect(asset_kinds(release)).to include("win_x64", "mac_x64")
expect(asset_kinds(release)).not_to include("linux_x64")
end
it "exposes expected_kinds from included Build concerns" do
expect(described_class.expected_kinds).to match_array(
%w[cartridge source html docs win_x64 linux_x64 mac_x64]
)
end
end
+59
View File
@@ -0,0 +1,59 @@
require "rails_helper"
RSpec.describe WarpEngine::UpdateService do
describe "#update" do
it "raises ArgumentError for unsupported platform" do
input = WarpEngine::UpdateInputDto.new(platform: "unknown", name: "game", version: "1.0")
expect { described_class.new.update(input) }.to raise_error(ArgumentError, /Unsupported platform/)
end
it "routes to correct platform service" do
input = WarpEngine::UpdateInputDto.new(platform: "tic80", name: "game", version: "1.0")
mock_service = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:update)
described_class.new.update(input)
expect(mock_service).to have_received(:update).with("game", "1.0")
end
it "routes godot platform to GodotService" do
input = WarpEngine::UpdateInputDto.new(platform: "godot", name: "game", version: "1.0")
mock_service = instance_double(WarpEngine::SoftwareUpdater::GodotService)
allow(WarpEngine::SoftwareUpdater::GodotService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:update)
described_class.new.update(input)
expect(mock_service).to have_received(:update).with("game", "1.0")
end
it "routes bevy platform to BevyService" do
input = WarpEngine::UpdateInputDto.new(platform: "bevy", name: "game", version: "1.0")
mock_service = instance_double(WarpEngine::SoftwareUpdater::BevyService)
allow(WarpEngine::SoftwareUpdater::BevyService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:update)
described_class.new.update(input)
expect(mock_service).to have_received(:update).with("game", "1.0")
end
it "routes phaser platform to PhaserService" do
input = WarpEngine::UpdateInputDto.new(platform: "phaser", name: "game", version: "1.0")
mock_service = instance_double(WarpEngine::SoftwareUpdater::PhaserService)
allow(WarpEngine::SoftwareUpdater::PhaserService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:update)
described_class.new.update(input)
expect(mock_service).to have_received(:update).with("game", "1.0")
end
end
end
+14
View File
@@ -0,0 +1,14 @@
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end
config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus
config.order = :random
Kernel.srand config.seed
end