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:
@@ -0,0 +1,4 @@
|
||||
log/
|
||||
spec/dummy/log/
|
||||
spec/dummy/tmp/
|
||||
Gemfile.lock
|
||||
@@ -0,0 +1,12 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gemspec
|
||||
|
||||
gem "mysql2", "~> 0.5"
|
||||
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "factory_bot_rails"
|
||||
gem "shoulda-matchers", "~> 6.0"
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
require "bundler/setup"
|
||||
|
||||
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
||||
load "rails/tasks/engine.rake"
|
||||
|
||||
require "rspec/core/rake_task"
|
||||
RSpec::Core::RakeTask.new(:spec)
|
||||
task default: :spec
|
||||
@@ -0,0 +1,15 @@
|
||||
module WarpEngine
|
||||
class FileResultDto
|
||||
attr_reader :type, :path, :url
|
||||
|
||||
def initialize(type:, path: nil, url: nil)
|
||||
@type = type
|
||||
@path = path
|
||||
@url = url
|
||||
end
|
||||
|
||||
def self.file(path) = new(type: :file, path: path)
|
||||
def self.redirect(url) = new(type: :redirect, url: url)
|
||||
def self.not_found = new(type: :not_found)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
module WarpEngine
|
||||
FileShowInputDto = Struct.new(:path, keyword_init: true)
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
module WarpEngine
|
||||
ImageShowInputDto = Struct.new(:id, keyword_init: true)
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module WarpEngine
|
||||
UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
def initialize(platform:, name:, version: nil)
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
module WarpEngine
|
||||
module TimestampFields
|
||||
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
|
||||
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module WarpEngine
|
||||
class ExternalLinkSerializer < Blueprinter::Base
|
||||
include TimestampFields
|
||||
|
||||
field(:id) { |el| el.id }
|
||||
field(:createdAt) { |el| el.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |el| el.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |el| el.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:softwareId) { |el| el.software_id }
|
||||
field :label
|
||||
field :url
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
module WarpEngine
|
||||
class PlatformLinkSerializer < Blueprinter::Base
|
||||
field :name
|
||||
field :url
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
module WarpEngine
|
||||
class ReleaseSerializer < Blueprinter::Base
|
||||
include TimestampFields
|
||||
|
||||
FILE_PATH_TO = "/file/"
|
||||
|
||||
# A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból.
|
||||
def self.file_path_from
|
||||
"#{WarpEngine.config.file_container_path.chomp("/")}/"
|
||||
end
|
||||
|
||||
def self.assets_of(release)
|
||||
release.association(:release_assets).loaded? ? release.release_assets : release.release_assets.to_a
|
||||
end
|
||||
|
||||
def self.asset_path(release, kind)
|
||||
asset = assets_of(release).find { |a| a.kind == kind }
|
||||
asset ? asset.path.gsub(file_path_from, FILE_PATH_TO) : ""
|
||||
end
|
||||
|
||||
field(:id) { |r| r.id }
|
||||
field(:createdAt) { |r| r.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |r| r.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |r| r.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:softwareId) { |r| r.software_id }
|
||||
field :version
|
||||
field(:cartridgePath) { |r| asset_path(r, "cartridge") }
|
||||
field(:sourcePath) { |r| asset_path(r, "source") }
|
||||
field(:htmlFolderPath) { |r| asset_path(r, "html") }
|
||||
field(:docsFolderPath) { |r| asset_path(r, "docs") }
|
||||
field(:assets) do |r|
|
||||
assets_of(r).map { |a| { kind: a.kind, path: a.path.gsub(file_path_from, FILE_PATH_TO) } }
|
||||
end
|
||||
field(:downloadCount) do |r, opts|
|
||||
counts = opts[:download_counts]
|
||||
counts ? counts.fetch(r.id, 0) : r.downloads.count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module WarpEngine
|
||||
class SoftwareDetailSerializer < Blueprinter::Base
|
||||
field(:software) { |sw, _| SoftwareSerializer.render_as_hash(sw) }
|
||||
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases], download_counts: opts[:download_counts]) }
|
||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
||||
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module WarpEngine
|
||||
class SoftwareSerializer < Blueprinter::Base
|
||||
include TimestampFields
|
||||
|
||||
field(:id) { |sw| sw.id }
|
||||
field(:createdAt) { |sw| sw.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |sw| sw.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |sw| sw.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field :name
|
||||
field :title
|
||||
field :author
|
||||
field(:desc) { |sw| sw.desc.to_s }
|
||||
field(:story) { |sw| sw.story.to_s }
|
||||
field(:license) { |sw| sw.license.to_s }
|
||||
field :platform
|
||||
field :status
|
||||
field(:highlighted) { |sw| sw.highlighted ? true : false }
|
||||
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
||||
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
|
||||
field(:imageUrl) { |sw|
|
||||
si = sw.software_images.detect(&:is_default?) || sw.software_images.first
|
||||
si ? "/api/image/#{si.image_id}" : nil
|
||||
}
|
||||
field(:images) { |sw|
|
||||
sw.software_images.map { |si|
|
||||
{ url: "/api/image/#{si.image_id}", isDefault: si.is_default?, position: si.position }
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
require "zip"
|
||||
require "fileutils"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module ArchiveExtraction
|
||||
private
|
||||
|
||||
def base_path
|
||||
@base_path ||= WarpEngine.config.file_container_path
|
||||
end
|
||||
|
||||
def full_path(filename)
|
||||
File.join(base_path, filename)
|
||||
end
|
||||
|
||||
def dir_exists?(dirname)
|
||||
File.directory?(full_path(dirname))
|
||||
end
|
||||
|
||||
def delete_dir(dirname)
|
||||
FileUtils.rm_rf(full_path(dirname))
|
||||
end
|
||||
|
||||
def create_dir(dirname)
|
||||
FileUtils.mkdir_p(full_path(dirname))
|
||||
end
|
||||
|
||||
def unzip_file(zip_filename, dest_dirname)
|
||||
dest = full_path(dest_dirname)
|
||||
Zip::File.open(full_path(zip_filename)) do |zip|
|
||||
zip.each do |entry|
|
||||
entry_dest = File.join(dest, entry.name)
|
||||
FileUtils.mkdir_p(File.dirname(entry_dest))
|
||||
entry.extract(entry_dest) { true }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def extract_zip_to_dir(zip_file, dir_name)
|
||||
delete_dir(dir_name) if dir_exists?(dir_name)
|
||||
create_dir(dir_name)
|
||||
unzip_file(zip_file, dir_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildCartridge
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "cartridge"
|
||||
end
|
||||
|
||||
def cartridge_ext
|
||||
raise NotImplementedError, "#{self.class} must implement #cartridge_ext"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cartridge_asset_path(versioned)
|
||||
path = full_path("#{versioned}#{cartridge_ext}")
|
||||
{ "cartridge" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildDocs
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "docs"
|
||||
register_prepare_step :prepare_docs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_docs(versioned)
|
||||
docs_zip = "#{versioned}-docs.zip"
|
||||
extract_zip_to_dir(docs_zip, "#{versioned}-docs") if File.file?(full_path(docs_zip))
|
||||
end
|
||||
|
||||
def docs_asset_path(versioned)
|
||||
docs_dir = "#{versioned}-docs"
|
||||
{ "docs" => full_path(docs_dir) } if dir_exists?(docs_dir)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildLinuxX64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "linux_x64"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def linux_x64_asset_path(versioned)
|
||||
path = full_path("#{versioned}-linux-x64.zip")
|
||||
{ "linux_x64" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildMacArm64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "mac_arm64"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mac_arm64_asset_path(versioned)
|
||||
path = full_path("#{versioned}-mac-arm64.zip")
|
||||
{ "mac_arm64" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildMacUniversal
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "mac_universal"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mac_universal_asset_path(versioned)
|
||||
path = full_path("#{versioned}-mac-universal.zip")
|
||||
{ "mac_universal" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildMacX64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "mac_x64"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mac_x64_asset_path(versioned)
|
||||
path = full_path("#{versioned}-mac-x64.zip")
|
||||
{ "mac_x64" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildSource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "source"
|
||||
end
|
||||
|
||||
def source_ext
|
||||
raise NotImplementedError, "#{self.class} must implement #source_ext"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def source_asset_path(versioned)
|
||||
path = full_path("#{versioned}#{source_ext}")
|
||||
{ "source" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildWeb
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "html"
|
||||
register_prepare_step :prepare_web
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_web(versioned)
|
||||
extract_zip_to_dir("#{versioned}.html.zip", versioned)
|
||||
end
|
||||
|
||||
def html_asset_path(versioned)
|
||||
path = full_path(versioned)
|
||||
{ "html" => path } if dir_exists?(versioned)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildWinX64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "win_x64"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def win_x64_asset_path(versioned)
|
||||
path = full_path("#{versioned}-win-x64.zip")
|
||||
{ "win_x64" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Builds
|
||||
module BuildWinX86
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "win_x86"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def win_x86_asset_path(versioned)
|
||||
path = full_path("#{versioned}-win-x86.zip")
|
||||
{ "win_x86" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "json"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module MetadataParsing
|
||||
METADATA_KEYS = %i[name title author desc site repo license].freeze
|
||||
|
||||
private
|
||||
|
||||
def parse_json_metadata(path)
|
||||
raw = JSON.parse(File.read(path), symbolize_names: true)
|
||||
raw.slice(*METADATA_KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module SoftwarePersistence
|
||||
private
|
||||
|
||||
def update_or_create_software(attrs)
|
||||
software = WarpEngine::Software.unscoped.find_or_initialize_by(name: attrs[:name])
|
||||
software.assign_attributes(attrs.except(:name))
|
||||
software.deleted_at = nil
|
||||
software.save!
|
||||
software
|
||||
end
|
||||
|
||||
def upsert_external_link(software_id, label, url)
|
||||
link = WarpEngine::ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
|
||||
link.url = url
|
||||
link.deleted_at = nil
|
||||
link.save!
|
||||
end
|
||||
|
||||
def create_release_if_not_exists(attrs)
|
||||
existing = WarpEngine::Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
|
||||
return existing if existing
|
||||
|
||||
WarpEngine::Release.create!(attrs)
|
||||
end
|
||||
|
||||
def sync_release_assets(release, kind_paths)
|
||||
kind_paths.each do |kind, path|
|
||||
asset = WarpEngine::ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind)
|
||||
asset.path = path
|
||||
asset.deleted_at = nil
|
||||
asset.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Updatable
|
||||
extend ActiveSupport::Concern
|
||||
include ArchiveExtraction
|
||||
include MetadataParsing
|
||||
include SoftwarePersistence
|
||||
|
||||
class_methods do
|
||||
def platform(value = nil)
|
||||
@platform = value if value
|
||||
@platform ||= name.demodulize.delete_suffix("Service").downcase
|
||||
end
|
||||
|
||||
def label(value = nil)
|
||||
@label = value if value
|
||||
@label ||= platform.titleize
|
||||
end
|
||||
|
||||
def expected_kinds
|
||||
@expected_kinds ||= []
|
||||
end
|
||||
|
||||
def register_expected_kind(kind)
|
||||
expected_kinds << kind unless expected_kinds.include?(kind)
|
||||
end
|
||||
|
||||
def prepare_steps
|
||||
@prepare_steps ||= []
|
||||
end
|
||||
|
||||
def register_prepare_step(method_name)
|
||||
prepare_steps << method_name unless prepare_steps.include?(method_name)
|
||||
end
|
||||
end
|
||||
|
||||
def update(name, version)
|
||||
versioned = "#{name}-#{version}"
|
||||
prepare_files(versioned)
|
||||
|
||||
metadata = parse_metadata(versioned)
|
||||
site_url = metadata.delete(:site)
|
||||
repo_url = metadata.delete(:repo)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
software = update_or_create_software(metadata.merge(platform: self.class.platform))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
upsert_external_link(software.id, "Repository", repo_url) if repo_url.present?
|
||||
|
||||
release = create_release_if_not_exists(software_id: software.id, version: version)
|
||||
sync_release_assets(release, collect_asset_paths(versioned))
|
||||
release
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_files(versioned)
|
||||
self.class.prepare_steps.each { |step| send(step, versioned) }
|
||||
end
|
||||
|
||||
def parse_metadata(versioned)
|
||||
parse_json_metadata(full_path("#{versioned}.metadata.json"))
|
||||
end
|
||||
|
||||
def collect_asset_paths(versioned)
|
||||
self.class.expected_kinds.each_with_object({}) do |kind, hash|
|
||||
result = send(:"#{kind}_asset_path", versioned)
|
||||
hash.merge!(result) if result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
module WarpEngine
|
||||
class BuildsService
|
||||
def index
|
||||
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{platform.camelize}Service".constantize
|
||||
hash[platform] = {
|
||||
label: service_class.label,
|
||||
kinds: service_class.expected_kinds
|
||||
}
|
||||
end
|
||||
|
||||
all_kinds = WarpEngine::ReleaseAsset::KINDS
|
||||
|
||||
{ platforms: platforms, allKinds: all_kinds }
|
||||
end
|
||||
|
||||
def show(name)
|
||||
software = WarpEngine::Software.find_by!(name: name)
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{software.platform.camelize}Service".constantize
|
||||
expected = service_class.expected_kinds
|
||||
|
||||
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
|
||||
|
||||
release_data = releases.each_with_object({}) do |release, hash|
|
||||
actual = release.release_assets.map(&:kind)
|
||||
hash[release.version] = {
|
||||
actual: actual,
|
||||
missing: expected - actual
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
platform: software.platform,
|
||||
expected: expected,
|
||||
releases: release_data
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
module WarpEngine
|
||||
class DownloadService
|
||||
def self.container_base
|
||||
WarpEngine.config.file_container_path
|
||||
end
|
||||
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
def self.base_path
|
||||
Pathname.new(container_base).realpath
|
||||
end
|
||||
|
||||
def create(path:, ip:, user_agent:, referer:)
|
||||
sanitized = path.to_s
|
||||
base_path = self.class.base_path
|
||||
full_path = base_path.join(sanitized).realpath
|
||||
return nil unless full_path.to_s.start_with?(base_path.to_s)
|
||||
return nil unless File.file?(full_path)
|
||||
|
||||
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
|
||||
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
|
||||
WarpEngine::Download.create!(
|
||||
file_path: sanitized,
|
||||
release: asset&.release,
|
||||
ip_address: ip,
|
||||
user_agent: user_agent&.truncate(500),
|
||||
referer: referer&.truncate(500)
|
||||
)
|
||||
|
||||
full_path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
module WarpEngine
|
||||
class FileManagerService
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
def base_path
|
||||
@base_path ||= Pathname.new(WarpEngine.config.file_container_path)
|
||||
end
|
||||
|
||||
def list(relative_path = "")
|
||||
full = safe_path!(relative_path)
|
||||
raise ArgumentError, "Not a directory" unless full.directory?
|
||||
|
||||
entries = full.children.sort_by { |c| [ c.directory? ? 0 : 1, c.basename.to_s.downcase ] }
|
||||
entries.map do |child|
|
||||
stat = child.stat
|
||||
{
|
||||
name: child.basename.to_s,
|
||||
path: child.relative_path_from(base_path).to_s,
|
||||
type: child.directory? ? :directory : :file,
|
||||
size: child.directory? ? nil : stat.size,
|
||||
mtime: stat.mtime
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
def upload(relative_dir, uploaded_file)
|
||||
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
|
||||
dir = safe_path!(relative_dir)
|
||||
raise ArgumentError, "Not a directory" unless dir.directory?
|
||||
|
||||
safe_name = sanitize_name(uploaded_file.original_filename)
|
||||
target = dir.join(safe_name)
|
||||
raise ArgumentError, "Path escape" unless target.to_s.start_with?(base_path.to_s)
|
||||
|
||||
IO.copy_stream(uploaded_file.to_io, target.to_s)
|
||||
target.relative_path_from(base_path).to_s
|
||||
end
|
||||
|
||||
def delete(relative_path)
|
||||
full = safe_path!(relative_path)
|
||||
raise ArgumentError, "Cannot delete root" if full == base_path
|
||||
|
||||
if full.directory?
|
||||
full.rmdir
|
||||
else
|
||||
full.delete
|
||||
end
|
||||
end
|
||||
|
||||
def rename(relative_path, new_name)
|
||||
full = safe_path!(relative_path)
|
||||
raise ArgumentError, "Cannot rename root" if full == base_path
|
||||
|
||||
safe_name = sanitize_name(new_name)
|
||||
new_full = full.parent.join(safe_name)
|
||||
raise ArgumentError, "Path escape" unless new_full.to_s.start_with?(base_path.to_s)
|
||||
|
||||
full.rename(new_full)
|
||||
new_full.relative_path_from(base_path).to_s
|
||||
end
|
||||
|
||||
def mkdir(relative_path, folder_name)
|
||||
parent = safe_path!(relative_path)
|
||||
raise ArgumentError, "Not a directory" unless parent.directory?
|
||||
|
||||
safe_name = sanitize_name(folder_name)
|
||||
new_dir = parent.join(safe_name)
|
||||
raise ArgumentError, "Path escape" unless new_dir.to_s.start_with?(base_path.to_s)
|
||||
|
||||
new_dir.mkdir
|
||||
new_dir.relative_path_from(base_path).to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def safe_path!(relative_path)
|
||||
cleaned = relative_path.to_s.gsub("..", "").squeeze("/").gsub(%r{^/|/$}, "")
|
||||
full = base_path.join(cleaned)
|
||||
resolved = full.exist? ? full.realpath : full.cleanpath
|
||||
unless resolved.to_s.start_with?(base_path.to_s)
|
||||
raise ArgumentError, "Path traversal detected"
|
||||
end
|
||||
resolved
|
||||
end
|
||||
|
||||
def sanitize_name(name)
|
||||
name.to_s.gsub("..", "").gsub("/", "").gsub("\\", "").strip.tap do |n|
|
||||
raise ArgumentError, "Invalid name" if n.blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
module WarpEngine
|
||||
class FileService
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
def self.base_path
|
||||
Pathname.new(WarpEngine.config.file_container_path).realpath
|
||||
end
|
||||
|
||||
def show(input)
|
||||
full_path = base_path.join(input.path.to_s)
|
||||
return FileResultDto.not_found unless safe_path?(full_path)
|
||||
|
||||
if File.directory?(full_path)
|
||||
index_path = full_path.join("index.html")
|
||||
return FileResultDto.not_found unless File.file?(index_path)
|
||||
return FileResultDto.redirect("/file/#{input.path.to_s.chomp("/")}/index.html")
|
||||
end
|
||||
|
||||
if File.file?(full_path)
|
||||
FileResultDto.file(full_path)
|
||||
else
|
||||
FileResultDto.not_found
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_path
|
||||
@base_path ||= self.class.base_path
|
||||
end
|
||||
|
||||
def safe_path?(path)
|
||||
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module WarpEngine
|
||||
class ImageService
|
||||
def show(input)
|
||||
WarpEngine::Image.find(input.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module WarpEngine
|
||||
class SoftwareHighlightedService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
software = WarpEngine::Software.includes(:external_links, :software_images)
|
||||
.where(highlighted: true)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
return nil unless software
|
||||
|
||||
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
|
||||
build_response(software, releases, download_counts_for(releases.map(&:id)))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
module WarpEngine
|
||||
module SoftwareResponseBuilder
|
||||
private
|
||||
|
||||
def build_response(software, releases, download_counts)
|
||||
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
|
||||
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
||||
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
||||
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
|
||||
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
|
||||
|
||||
SoftwareDetailSerializer.render_as_hash(software,
|
||||
releases: sorted,
|
||||
latest: latest,
|
||||
web_playable: web_playable,
|
||||
total_downloads: total_downloads,
|
||||
download_counts: download_counts
|
||||
)
|
||||
end
|
||||
|
||||
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
|
||||
def download_counts_for(release_ids)
|
||||
return {} if release_ids.empty?
|
||||
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module WarpEngine
|
||||
class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
||||
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class BevyService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
|
||||
label "Bevy"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class C64Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
|
||||
label "C64"
|
||||
|
||||
def cartridge_ext = ".prg"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class EbitengineService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
include Builds::BuildMacArm64
|
||||
|
||||
label "Ebitengine"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class GodotService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX86
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "Godot"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class LoveService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacUniversal
|
||||
|
||||
label "LÖVE"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class PhaserService
|
||||
include Updatable
|
||||
include Builds::BuildWeb
|
||||
|
||||
label "Phaser"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
class Tic80Service
|
||||
include Updatable
|
||||
include Builds::BuildCartridge
|
||||
include Builds::BuildSource
|
||||
include Builds::BuildWeb
|
||||
include Builds::BuildDocs
|
||||
include Builds::BuildWinX64
|
||||
include Builds::BuildLinuxX64
|
||||
include Builds::BuildMacX64
|
||||
|
||||
label "TIC-80"
|
||||
|
||||
def cartridge_ext = ".tic"
|
||||
def source_ext = ".lua"
|
||||
|
||||
private
|
||||
|
||||
def parse_metadata(versioned)
|
||||
parse_lua_metadata(full_path("#{versioned}.lua"))
|
||||
end
|
||||
|
||||
def parse_lua_metadata(source_path)
|
||||
metadata = {}
|
||||
File.foreach(source_path) do |line|
|
||||
break unless line.start_with?("--")
|
||||
parts = line[2..].split(":", 2)
|
||||
next if parts.length != 2
|
||||
key = parts[0].strip.downcase.to_sym
|
||||
value = parts[1].strip
|
||||
metadata[key] = value
|
||||
end
|
||||
metadata.slice(*MetadataParsing::METADATA_KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module WarpEngine
|
||||
class UpdateService
|
||||
def update(input)
|
||||
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
|
||||
raise ArgumentError, "Unsupported platform: #{input.platform}"
|
||||
end
|
||||
|
||||
"WarpEngine::SoftwareUpdater::#{input.platform.camelize}Service".constantize.new.update(input.name, input.version)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
require "blueprinter"
|
||||
|
||||
require "warp_engine/version"
|
||||
require "warp_engine/configuration"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
require_relative "config/application"
|
||||
Rails.application.load_tasks
|
||||
@@ -0,0 +1,4 @@
|
||||
require_relative "config/environment"
|
||||
|
||||
run Rails.application
|
||||
Rails.application.load_server
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__)
|
||||
|
||||
require "bundler/setup"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
require_relative "application"
|
||||
|
||||
Rails.application.initialize!
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
Rails.application.routes.draw do
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :release, class: "WarpEngine::Release" do
|
||||
software
|
||||
sequence(:version) { |n| "1.0.#{n}" }
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -18,4 +18,6 @@ Gem::Specification.new do |spec|
|
||||
spec.files = Dir["{app,config,db,lib}/**/*", "README.md"]
|
||||
|
||||
spec.add_dependency "rails", ">= 8.0"
|
||||
spec.add_dependency "blueprinter"
|
||||
spec.add_dependency "rubyzip", "~> 2.3"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user