diff --git a/Gemfile b/Gemfile index 0db8046..dd2026a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem "rails", "8.0.0.1" +gem "rails", "8.0.1" gem "acidic_job", "= 1.0.0.rc1" gem "aasm", "~> 5.5.0" @@ -11,7 +11,7 @@ gem "git", "~> 2.3.3" gem "kamal", "~> 2.4.0", require: false gem "thruster", "~> 0.1.9", require: false gem "mission_control-jobs", "~> 1.0.1" -gem "noticed", "~> 2.4.3" +gem "noticed", "~> 2.5.0" gem "litestream", "~> 0.12.0" gem "octokit", "~> 9.2.0" gem "omniauth-github", github: "omniauth/omniauth-github", branch: "master" @@ -20,11 +20,11 @@ gem "pagy", "~> 9.3.3" gem "phlex-rails", "~> 1.2.2" gem "propshaft", "~> 1.1.0" gem "puma", ">= 6.5.0" -gem "sentry-ruby", "~> 5.22.0" -gem "sentry-rails", "~> 5.22.0" +gem "sentry-ruby", "~> 5.22.1" +gem "sentry-rails", "~> 5.22.1" gem "stackprof" gem "solid_cache", "~> 1.0.6" -gem "solid_cable", "~> 3.0.4" +gem "solid_cable", "~> 3.0.5" gem "solid_queue", "~> 1.1.0" gem "sqlite3", "~> 2.4.1" gem "stimulus-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 85205c0..d2afcc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,29 +20,29 @@ GEM activerecord (>= 7.1) activesupport (>= 7.1) railties (>= 7.1) - actioncable (8.0.0.1) - actionpack (= 8.0.0.1) - activesupport (= 8.0.0.1) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.0.1) - actionpack (= 8.0.0.1) - activejob (= 8.0.0.1) - activerecord (= 8.0.0.1) - activestorage (= 8.0.0.1) - activesupport (= 8.0.0.1) + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) - actionmailer (8.0.0.1) - actionpack (= 8.0.0.1) - actionview (= 8.0.0.1) - activejob (= 8.0.0.1) - activesupport (= 8.0.0.1) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.0.1) - actionview (= 8.0.0.1) - activesupport (= 8.0.0.1) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -50,35 +50,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.0.1) - actionpack (= 8.0.0.1) - activerecord (= 8.0.0.1) - activestorage (= 8.0.0.1) - activesupport (= 8.0.0.1) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.0.1) - activesupport (= 8.0.0.1) + actionview (8.0.1) + activesupport (= 8.0.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.0.1) - activesupport (= 8.0.0.1) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (8.0.0.1) - activesupport (= 8.0.0.1) - activerecord (8.0.0.1) - activemodel (= 8.0.0.1) - activesupport (= 8.0.0.1) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) - activestorage (8.0.0.1) - actionpack (= 8.0.0.1) - activejob (= 8.0.0.1) - activerecord (= 8.0.0.1) - activesupport (= 8.0.0.1) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (8.0.0.1) + activesupport (8.0.1) base64 benchmark (>= 0.3) bigdecimal @@ -272,7 +272,7 @@ GEM bigdecimal (~> 3.1) net-http (0.6.0) uri - net-imap (0.5.1) + net-imap (0.5.2) date net-protocol net-pop (0.1.2) @@ -299,7 +299,7 @@ GEM racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) - noticed (2.4.3) + noticed (2.5.0) rails (>= 6.1.0) oauth2 (2.0.9) faraday (>= 0.17.3, < 3.0) @@ -362,20 +362,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (8.0.0.1) - actioncable (= 8.0.0.1) - actionmailbox (= 8.0.0.1) - actionmailer (= 8.0.0.1) - actionpack (= 8.0.0.1) - actiontext (= 8.0.0.1) - actionview (= 8.0.0.1) - activejob (= 8.0.0.1) - activemodel (= 8.0.0.1) - activerecord (= 8.0.0.1) - activestorage (= 8.0.0.1) - activesupport (= 8.0.0.1) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) bundler (>= 1.15.0) - railties (= 8.0.0.1) + railties (= 8.0.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -388,9 +388,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (8.0.0.1) - actionpack (= 8.0.0.1) - activesupport (= 8.0.0.1) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -448,10 +448,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.22.0) + sentry-rails (5.22.1) railties (>= 5.0) - sentry-ruby (~> 5.22.0) - sentry-ruby (5.22.0) + sentry-ruby (~> 5.22.1) + sentry-ruby (5.22.1) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) simplecov (0.22.0) @@ -466,7 +466,7 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - solid_cable (3.0.4) + solid_cable (3.0.5) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) @@ -574,7 +574,7 @@ DEPENDENCIES minio (~> 0.4.0) mission_control-jobs (~> 1.0.1) mocha (~> 2.7.1) - noticed (~> 2.4.3) + noticed (~> 2.5.0) octokit (~> 9.2.0) omniauth-github! omniauth-rails_csrf_protection @@ -583,15 +583,15 @@ DEPENDENCIES phlex-rails (~> 1.2.2) propshaft (~> 1.1.0) puma (>= 6.5.0) - rails (= 8.0.0.1) + rails (= 8.0.1) rails-erd (~> 1.7.2) rubocop-rails-omakase selenium-webdriver (~> 4.27.0) - sentry-rails (~> 5.22.0) - sentry-ruby (~> 5.22.0) + sentry-rails (~> 5.22.1) + sentry-ruby (~> 5.22.1) simplecov simplecov-tailwindcss - solid_cable (~> 3.0.4) + solid_cable (~> 3.0.5) solid_cache (~> 1.0.6) solid_queue (~> 1.1.0) sqlite3 (~> 2.4.1) diff --git a/app/controllers/generated_apps_controller.rb b/app/controllers/generated_apps_controller.rb index e1db01e..98fb580 100644 --- a/app/controllers/generated_apps_controller.rb +++ b/app/controllers/generated_apps_controller.rb @@ -3,4 +3,27 @@ class GeneratedAppsController < ApplicationController def show @generated_app = GeneratedApp.find(params[:id]) end + + def create + cli_flags = [ + params[:api_flag], + params[:database_choice], + params[:rails_flags] + ].compact.join(" ") + + recipe = Recipe.find_or_create_by_cli_flags!(cli_flags, current_user) + + @generated_app = current_user.generated_apps.create!( + name: params[:app_name], + recipe: recipe, + ruby_version: recipe.ruby_version, + rails_version: recipe.rails_version, + selected_gems: [], # We'll handle this later with ingredients + configuration_options: {} # We'll handle this later with ingredients + ) + + AppGeneration::Orchestrator.new(@generated_app).call + + redirect_to generated_app_log_entries_path(@generated_app) + end end diff --git a/app/controllers/github_controller.rb b/app/controllers/github_controller.rb new file mode 100644 index 0000000..8bf2674 --- /dev/null +++ b/app/controllers/github_controller.rb @@ -0,0 +1,20 @@ +class GithubController < ApplicationController + before_action :authenticate_user! + + def check_name + validator = GithubRepositoryNameValidator.new( + params[:name], + current_user.github_username + ) + begin + exists = validator.repo_exists? + render json: { available: !exists } + rescue Octokit::Unauthorized, Octokit::Forbidden => e + Rails.logger.error("GitHub authentication error: #{e.message}") + render json: { error: "GitHub authentication failed" }, status: :unauthorized + rescue => e + Rails.logger.error("GitHub validation error: #{e.message}") + render json: { error: "Could not validate repository name" }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb deleted file mode 100644 index f831645..0000000 --- a/app/controllers/repositories_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -# app/controllers/repositories_controller.rb -class RepositoriesController < ApplicationController - before_action :authenticate_user! - before_action :set_user, except: [ :check_name ] - - def index - @repositories = @user.repositories - end - - def new - @repository = @user.repositories.build - end - - def create - begin - GithubRepositoryService.new(@user) - .create_repository(repository_params[:name]) - - redirect_to user_repositories_path(@user), notice: "Repository created successfully!" - rescue GithubRepositoryService::Error => e - redirect_to new_user_repository_path(@user), alert: e.message - end - end - - def check_name - validator = GithubRepositoryNameValidator.new( - params[:name], - current_user.github_username - ) - render json: { available: validator.valid? } - end - private - - def set_user - @user = User.friendly.find(params[:user_id]) - end - - def repository_params - params.require(:repository).permit(:name) - end - - def authenticate_user! - unless current_user - redirect_to root_path, alert: "Please sign in with GitHub first!" - end - end -end diff --git a/app/helpers/repostitories_helper.rb b/app/helpers/repostitories_helper.rb deleted file mode 100644 index 64efc65..0000000 --- a/app/helpers/repostitories_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module RepostitoriesHelper -end diff --git a/app/javascript/controllers/app_name_preview_controller.js b/app/javascript/controllers/app_name_preview_controller.js new file mode 100644 index 0000000..9b30de4 --- /dev/null +++ b/app/javascript/controllers/app_name_preview_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input"] + static outlets = ["generated-output"] + + connect() { + this.update() + } + + update(event) { + const value = event?.target?.value?.trim() || "" + this.generatedOutputOutlet.updateText(value) + this.dispatch("valueChanged", { detail: { value } }) + this.dispatch("appNameChanged", { detail: { value } }) + } +} diff --git a/app/javascript/controllers/form_values_controller.js b/app/javascript/controllers/form_values_controller.js new file mode 100644 index 0000000..6d3b836 --- /dev/null +++ b/app/javascript/controllers/form_values_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["appName", "apiFlag", "databaseChoice", "railsFlags"] + + connect() { + // Initialize values from the command display + this.updateFromDisplay() + } + + updateFromDisplay() { + const appNameOutput = document.getElementById("app-name-output") + const apiFlag = document.getElementById("api-flag") + const databaseChoice = document.getElementById("database-choice") + const railsFlags = document.getElementById("rails-flags") + + if (appNameOutput) { + this.appNameTarget.value = appNameOutput.textContent.trim() + const event = new Event('input', { bubbles: true }) + this.appNameTarget.dispatchEvent(event) + } + if (apiFlag) this.apiFlagTarget.value = apiFlag.textContent.trim() + if (databaseChoice) this.databaseChoiceTarget.value = databaseChoice.textContent.trim() + if (railsFlags) this.railsFlagsTarget.value = railsFlags.textContent.trim() + } +} diff --git a/app/javascript/controllers/generated_output_controller.js b/app/javascript/controllers/generated_output_controller.js index a44d7fd..7dc7fec 100644 --- a/app/javascript/controllers/generated_output_controller.js +++ b/app/javascript/controllers/generated_output_controller.js @@ -3,5 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { updateText(text) { this.element.innerText = text + this.dispatch("valueChanged") } } diff --git a/app/javascript/controllers/github_name_validator_controller.js b/app/javascript/controllers/github_name_validator_controller.js new file mode 100644 index 0000000..91c8ca7 --- /dev/null +++ b/app/javascript/controllers/github_name_validator_controller.js @@ -0,0 +1,102 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "message", "spinner", "submitButton"] + static values = { + checkUrl: String, + debounce: { type: Number, default: 500 }, + errorClass: { type: String, default: "text-red-600" }, + successClass: { type: String, default: "text-green-600" } + } + + initialize() { + this.validate = this.debounce(this.validate.bind(this), this.debounceValue) + this.disableSubmit() + this.boundValidate = this.validate.bind(this) + } + + connect() { + document.addEventListener("app-name-preview:appNameChanged", this.boundValidate) + } + + disconnect() { + document.removeEventListener("app-name-preview:appNameChanged", this.boundValidate) + } + + async validate(event) { + const name = event?.detail?.value || '' + if (!name) { + this.hideMessage() + this.disableSubmit() + return + } + + this.showSpinner() + + try { + const response = await fetch(`${this.checkUrlValue}?name=${encodeURIComponent(name)}`) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to validate repository name') + } + + const data = await response.json() + + this.hideSpinner() + + if (data.available) { + this.enableSubmit() + this.showMessage("✓ Name is available", this.successClassValue) + } else { + this.disableSubmit() + this.showMessage("✗ Name is already taken", this.errorClassValue) + } + } catch (error) { + this.hideSpinner() + this.disableSubmit() + this.showMessage("Error checking name availability", this.errorClassValue) + } + } + + showMessage(text, className) { + this.messageTarget.textContent = text + this.messageTarget.className = `${className} text-sm mt-1` + this.messageTarget.classList.remove("hidden") + } + + hideMessage() { + this.messageTarget.classList.add("hidden") + } + + showSpinner() { + this.spinnerTarget.classList.remove("hidden") + this.hideMessage() + } + + hideSpinner() { + this.spinnerTarget.classList.add("hidden") + } + + debounce(func, wait) { + let timeout + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout) + func(...args) + } + clearTimeout(timeout) + timeout = setTimeout(later, wait) + } + } + + disableSubmit() { + this.submitButtonTarget.disabled = true + this.submitButtonTarget.classList.add('opacity-50', 'cursor-not-allowed') + } + + enableSubmit() { + this.submitButtonTarget.disabled = false + this.submitButtonTarget.classList.remove('opacity-50', 'cursor-not-allowed') + } +} diff --git a/app/javascript/controllers/text_field_change_controller.js b/app/javascript/controllers/text_field_change_controller.js deleted file mode 100644 index 966cfa3..0000000 --- a/app/javascript/controllers/text_field_change_controller.js +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["input"] - static outlets = ["generated-output"] - - connect() { - this.update() - } - - update(event) { - const inputValue = this.inputTarget.value || this.inputTarget.dataset.defaultValue || "" - const prefix = this.inputTarget.dataset.outputPrefix || "" - - const updatedText = prefix ? `${prefix} ${inputValue}` : inputValue - this.generatedOutputOutlet.updateText(updatedText) - } -} diff --git a/app/jobs/app_generation_job.rb b/app/jobs/app_generation_job.rb index c9b3a8d..74a44c5 100644 --- a/app/jobs/app_generation_job.rb +++ b/app/jobs/app_generation_job.rb @@ -30,7 +30,7 @@ def create_github_repository def generate_rails_app @generated_app.generate! - command = build_rails_command + command = "rails new #{@generated_app.name} #{@generated_app.recipe.cli_flags}" CommandExecutionService.new(@generated_app, command).execute end @@ -49,8 +49,4 @@ def start_ci def complete_generation @generated_app.mark_as_completed! end - - def build_rails_command - "rails new #{@generated_app.name} --skip-action-mailbox --skip-jbuilder --asset-pipeline=propshaft --javascript=esbuild --css=tailwind --skip-spring" - end end diff --git a/app/models/concerns/git_backed_model.rb b/app/models/concerns/git_backed_model.rb index b2e7266..1b2fe28 100644 --- a/app/models/concerns/git_backed_model.rb +++ b/app/models/concerns/git_backed_model.rb @@ -26,9 +26,8 @@ def sync_to_git end def repo - @repo ||= GitRepo.new( + @repo ||= DataRepository.new( user: commit_author, - repo_name: repo_name ) end diff --git a/app/models/generated_app.rb b/app/models/generated_app.rb index f457ab6..3b342ff 100644 --- a/app/models/generated_app.rb +++ b/app/models/generated_app.rb @@ -92,8 +92,4 @@ def broadcast_clone_box locals: { generated_app: self } ) end - - def repo_name - name # Use the app's name as the repo name - end end diff --git a/app/models/recipe.rb b/app/models/recipe.rb index a728b8e..bf56d93 100644 --- a/app/models/recipe.rb +++ b/app/models/recipe.rb @@ -69,6 +69,23 @@ def reorder_ingredients!(new_order) end end + # Class method to find or create a recipe with given CLI flags + def self.find_or_create_by_cli_flags!(cli_flags, user) + transaction do + recipe = where(cli_flags: cli_flags, status: "published").first + return recipe if recipe + + create!( + name: "Rails App with #{cli_flags}", + cli_flags: cli_flags, + status: "published", + created_by: user, + ruby_version: RailsNewConfig.ruby_version_for_new_apps, + rails_version: RailsNewConfig.rails_version_for_new_apps + ) + end + end + private def ingredient_compatible?(new_ingredient) diff --git a/app/models/repository.rb b/app/models/repository.rb deleted file mode 100644 index ff12de7..0000000 --- a/app/models/repository.rb +++ /dev/null @@ -1,24 +0,0 @@ -# == Schema Information -# -# Table name: repositories -# -# id :integer not null, primary key -# github_url :string not null -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# user_id :integer not null -# -# Indexes -# -# index_repositories_on_github_url (github_url) UNIQUE -# index_repositories_on_name (name) -# index_repositories_on_user_id (user_id) -# -# Foreign Keys -# -# user_id (user_id => users.id) -# -class Repository < ApplicationRecord - belongs_to :user -end diff --git a/app/models/user.rb b/app/models/user.rb index 968ec26..257f71e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,7 +25,6 @@ class User < ApplicationRecord encrypts :github_token - has_many :repositories, dependent: :destroy has_many :generated_apps, dependent: :nullify has_many :notifications, as: :recipient, dependent: :destroy, diff --git a/app/services/app_generation/errors.rb b/app/services/app_generation/errors.rb new file mode 100644 index 0000000..c0c1805 --- /dev/null +++ b/app/services/app_generation/errors.rb @@ -0,0 +1,5 @@ +module AppGeneration + module Errors + class InvalidStateError < StandardError; end + end +end diff --git a/app/services/app_generation/orchestrator.rb b/app/services/app_generation/orchestrator.rb index 646f6f9..294fb14 100644 --- a/app/services/app_generation/orchestrator.rb +++ b/app/services/app_generation/orchestrator.rb @@ -11,18 +11,20 @@ def call AppGenerationJob.perform_later(@generated_app.id) true + rescue AppGeneration::Errors::InvalidStateError + raise rescue StandardError => e @logger.error("Failed to start app generation", { error: e.message }) - @generated_app.fail!(e.message) + @generated_app.mark_as_failed!(e.message) false end private def validate_initial_state! - unless @generated_app.pending? - raise InvalidStateError, "App must be in pending state to start generation" - end + return if @generated_app.pending? + + raise AppGeneration::Errors::InvalidStateError, "App must be in pending state to start generation" end end end diff --git a/app/services/app_repository.rb b/app/services/app_repository.rb new file mode 100644 index 0000000..fb3ee89 --- /dev/null +++ b/app/services/app_repository.rb @@ -0,0 +1,8 @@ +class AppRepository < GitRepo + def initialize(user:, app_name:) + super(user: user, repo_name: app_name) + ensure_app_repo_exists + end + + # App repo specific methods... +end diff --git a/app/services/data_repository.rb b/app/services/data_repository.rb new file mode 100644 index 0000000..4e4ba11 --- /dev/null +++ b/app/services/data_repository.rb @@ -0,0 +1,121 @@ +class DataRepository < GitRepo + BASE_NAME = "rails-new-io-data" + + class << self + def name_for_environment + if Rails.env.development? + "#{BASE_NAME}-dev" + elsif Rails.env.test? + "#{BASE_NAME}-test" + elsif Rails.env.production? + BASE_NAME + else + raise ArgumentError, "Unknown Rails environment: #{Rails.env}" + end + end + end + + def initialize(user:) + super(user: user, repo_name: self.class.name_for_environment) + end + + def write_model(model) + ensure_fresh_repo + + case model + when GeneratedApp + write_generated_app(model) + when Ingredient + write_ingredient(model) + when Recipe + write_recipe(model) + end + + push_to_remote + end + + protected + + def ensure_committable_state + %w[ingredients recipes].each do |dir| + FileUtils.mkdir_p(File.join(repo_path, dir)) + FileUtils.touch(File.join(repo_path, dir, ".keep")) + end + File.write(File.join(repo_path, "README.md"), readme_content) + end + + def write_generated_app(app) + raise NotImplementedError, "Generated apps are stored in their own repositories" + end + + private + + def write_ingredient(ingredient) + path = File.join(repo_path, "ingredients", ingredient.name.parameterize) + FileUtils.mkdir_p(path) + + File.write(File.join(path, "template.rb"), ingredient.template_content) + write_json(path, "metadata.json", { + name: ingredient.name, + description: ingredient.description, + conflicts_with: ingredient.conflicts_with, + requires: ingredient.requires, + configures_with: ingredient.configures_with + }) + end + + def write_recipe(recipe) + path = File.join(repo_path, "recipes", recipe.id.to_s) + FileUtils.mkdir_p(path) + + write_json(path, "manifest.json", { + name: recipe.name, + cli_flags: recipe.cli_flags, + ruby_version: recipe.ruby_version, + rails_version: recipe.rails_version + }) + + write_json(path, "ingredients.json", + recipe.recipe_ingredients.order(:position).map(&:to_git_format) + ) + end + + def ensure_fresh_repo + git.fetch + git.reset_hard("origin/main") if remote_branch_exists?("main") + git.pull if remote_branch_exists?("main") + + # Check if directories exist in repo + dirs_exist = %w[ingredients recipes].all? do |dir| + File.directory?(File.join(repo_path, dir)) + end + + unless dirs_exist + ensure_committable_state + git.add(all: true) + if git.status.changed.any? || git.status.added.any? + git.commit("Initialize repository structure") + git.push("origin", "main") + end + end + end + + def push_to_remote + git.push("origin", "main") + rescue Git::Error => e + Rails.logger.error "Git push failed: #{e.message}" + raise GitSyncError, "Failed to sync changes to GitHub" + end + + def readme_content + "# Data Repository\nThis repository contains data for rails-new.io" + end + + def repository_description + "Data repository for rails-new.io" + end + + def write_json(path, filename, content) + File.write(File.join(path, filename), content.to_json) + end +end diff --git a/app/services/git_repo.rb b/app/services/git_repo.rb index 5a7ff14..b3f062f 100644 --- a/app/services/git_repo.rb +++ b/app/services/git_repo.rb @@ -1,5 +1,4 @@ class GitRepo - REPO_NAME = "rails-new-io-data" class Error < StandardError; end class GitSyncError < Error; end @@ -7,164 +6,115 @@ def initialize(user:, repo_name:) @user = user @repo_path = Rails.root.join("tmp", "git_repos", user.id.to_s, repo_name) @repo_name = repo_name - ensure_repo_exists end - def write_model(model) - ensure_fresh_repo - - case model - when GeneratedApp - write_generated_app(model) - when Ingredient - write_ingredient(model) - when Recipe - write_recipe(model) + def commit_changes(message:, author:) + if File.exist?(repo_path) + raise GitSyncError, "Remote repository does not exist" unless remote_repo_exists? + git.fetch + if remote_branch_exists?("main") + git.reset_hard("origin/main") + end + else + if remote_repo_exists? + Git.clone( + "https://#{user.github_token}@github.com/#{user.github_username}/#{repo_name}.git", + repo_name, + path: File.dirname(repo_path) + ) + @git = Git.open(repo_path) + else + create_local_repo + ensure_github_repo_exists + setup_remote + end end - push_to_remote - end + ensure_committable_state - private + git.config("user.name", author.name || author.github_username) + git.config("user.email", author.email || "#{author.github_username}@users.noreply.github.com") - def repo_name - if Rails.env.development? - "#{REPO_NAME}-dev" - elsif Rails.env.test? - "#{REPO_NAME}-test" - elsif Rails.env.production? - REPO_NAME - else - raise ArgumentError, "Unknown Rails environment: #{Rails.env}" + git.add(all: true) + + # Only commit if there are changes + if git.status.changed.any? || git.status.added.any? || git.status.deleted.any? + git.commit(message) + current_branch = git.branch.name + git.push("origin", current_branch) end end - def ensure_repo_exists - return if remote_repo_exists? + protected - create_local_repo - create_github_repo - setup_remote - create_initial_structure - end + attr_reader :user, :repo_path, :repo_name - def remote_repo_exists? - github_client.repository?("#{@user.github_username}/#{repo_name}") - rescue Octokit::Error => e - Rails.logger.error("Failed to check GitHub repository: #{e.message}") - false + def git + @git ||= begin + if File.exist?(File.join(@repo_path, ".git")) + Git.open(@repo_path) + else + create_local_repo + @git + end + end end def create_local_repo - # Ensure parent directory exists first FileUtils.mkdir_p(File.dirname(@repo_path)) - - # Remove existing repo if it exists FileUtils.rm_rf(@repo_path) if File.exist?(@repo_path) - - # Create fresh directory and initialize git FileUtils.mkdir_p(@repo_path) - git = Git.init(@repo_path) - # Configure git to avoid template issues - git.config("init.templateDir", "") + @git = Git.init(@repo_path) + + @git.config("init.templateDir", "") + @git.config("init.defaultBranch", "main") + end - @git = git + def remote_repo_exists? + github_client.repository?("#{user.github_username}/#{repo_name}") + rescue Octokit::Error => e + Rails.logger.error("Failed to check GitHub repository: #{e.message}") + false end def create_github_repo github_client.create_repository( repo_name, private: false, - description: "Data repository for rails-new.io" + description: repository_description ) end - def create_initial_structure - %w[generated_apps ingredients recipes].each do |dir| - FileUtils.mkdir_p(File.join(@repo_path, dir)) - end - - File.write(File.join(@repo_path, "README.md"), readme_content) - - git.add(all: true) - git.commit("Initial commit") - end - - def write_generated_app(app) - path = File.join(@repo_path, "generated_apps", app.id.to_s) - FileUtils.mkdir_p(path) - - write_json(path, "current_state.json", { - name: app.name, - recipe_id: app.recipe_id, - configuration: app.configuration_options - }) - - write_json(path, "history.json", app.app_changes.map(&:to_git_format)) - end - - def write_ingredient(ingredient) - path = File.join(@repo_path, "ingredients", ingredient.name.parameterize) - FileUtils.mkdir_p(path) - - File.write(File.join(path, "template.rb"), ingredient.template_content) - write_json(path, "metadata.json", { - name: ingredient.name, - description: ingredient.description, - conflicts_with: ingredient.conflicts_with, - requires: ingredient.requires, - configures_with: ingredient.configures_with - }) + def setup_remote + remote_url = "https://#{user.github_token}@github.com/#{user.github_username}/#{repo_name}.git" + git.add_remote("origin", remote_url) end - def write_recipe(recipe) - path = File.join(@repo_path, "recipes", recipe.id.to_s) - FileUtils.mkdir_p(path) - - write_json(path, "manifest.json", { - name: recipe.name, - cli_flags: recipe.cli_flags, - ruby_version: recipe.ruby_version, - rails_version: recipe.rails_version - }) - - write_json(path, "ingredients.json", - recipe.recipe_ingredients.order(:position).map(&:to_git_format) - ) + def github_client + @_github_client ||= Octokit::Client.new(access_token: user.github_token) end - def ensure_fresh_repo - git.fetch - git.reset_hard("origin/main") - git.pull + def ensure_committable_state + File.write(File.join(repo_path, "README.md"), default_readme_content) end - def push_to_remote - git.push("origin", "main") - rescue Git::Error => e - # Handle push conflicts - Rails.logger.error "Git push failed: #{e.message}" - raise GitSyncError, "Failed to sync changes to GitHub" + def default_readme_content + "# Repository\nCreated via railsnew.io" end - def git - @git ||= Git.open(@repo_path) - end + private - def github_client - @github_client ||= Octokit::Client.new(access_token: @user.github_token) + def repository_description + "Repository created via railsnew.io" end - def write_json(path, filename, data) - File.write( - File.join(path, filename), - JSON.pretty_generate(data) - ) + def ensure_github_repo_exists + return if remote_repo_exists? + create_github_repo end - def setup_remote - remote_url = "https://#{@user.github_token}@github.com/#{@user.github_username}/#{repo_name}.git" - git.add_remote("origin", remote_url) + def remote_branch_exists?(branch_name) + git.branches.remote.map(&:name).include?("origin/#{branch_name}") end end diff --git a/app/services/github_repository_name_validator.rb b/app/services/github_repository_name_validator.rb index 44a9177..7d070f4 100644 --- a/app/services/github_repository_name_validator.rb +++ b/app/services/github_repository_name_validator.rb @@ -2,31 +2,37 @@ class GithubRepositoryNameValidator VALID_FORMAT = /\A[a-zA-Z0-9][a-zA-Z0-9-]*(? e + Rails.logger.error("GitHub API error: #{e.message}") + raise e end end end diff --git a/app/services/github_repository_service.rb b/app/services/github_repository_service.rb index a0530f1..a6f7a74 100644 --- a/app/services/github_repository_service.rb +++ b/app/services/github_repository_service.rb @@ -29,10 +29,6 @@ def create_repository(name) response = client.create_repository(name, options) - @user.repositories.create!( - name: name, - github_url: response.html_url - ) @generated_app.update!( github_repo_name: name, github_repo_url: response.html_url diff --git a/app/views/components/empty_state/component.rb b/app/views/components/empty_state/component.rb index 0496d66..511eef8 100644 --- a/app/views/components/empty_state/component.rb +++ b/app/views/components/empty_state/component.rb @@ -15,7 +15,7 @@ def view_template div(class: "mt-6 mb-8") do link_to( - new_user_repository_path(@user), + page_path("basic-setup"), class: "inline-flex items-center rounded-md bg-[#ac3b61] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#e935a3] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ac3b61]" ) do svg(class: "-ml-0.5 mr-1.5 size-5", viewbox: "0 0 20 20", fill: "currentColor", aria_hidden: "true", data_slot: "icon") do |s| diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb index 8512801..2671035 100644 --- a/app/views/dashboard/show.html.erb +++ b/app/views/dashboard/show.html.erb @@ -1,6 +1,5 @@ <%= turbo_stream_from [ :generated_app, current_user.id ] %> <%= turbo_stream_from [ :notification_badge, current_user.id ] %> -
@@ -26,17 +25,15 @@ autosubmit_target: "input" } %> <% end %> -
- + <% end %>
<% end %>
- <% if @generated_apps.any? %>
<%= turbo_frame_tag "generated_apps_list" do %> diff --git a/app/views/pages/show.html.erb b/app/views/pages/show.html.erb index e512438..b267863 100644 --- a/app/views/pages/show.html.erb +++ b/app/views/pages/show.html.erb @@ -1,33 +1,48 @@ -
- - -
-
- -
- +
+
+ + +
+
+ +
+ +
+ + +
-
-
+
+ class="text-gray-200 max-w-6xl mt-3 sm:mt-3 md:mt-3 lg:mt-4 xl:mt-4 h-auto sm:h-auto md:h-20 lg:h-20 xl:h-auto mb-6 mx-auto flex items-center">

+ class="font-mono text-left px-5 sm:px-5 md:px-8 lg:px-10 xl:px-12 text-xs md:text-sm lg:text-sm xl:text-sm break-normal"> rails new my_app @@ -36,8 +51,7 @@

-
+
📋️ Copy to Clipboard @@ -45,11 +59,29 @@ class="hidden bg-apple md:rounded-lg ml-0 md:mr-5 px-6 text-gray-100 shadow-2xl whitespace-no-wrap py-3"> 📋️ Copied to Clipboard!
- - 🏆️ Generate My App - + <%= form_tag generated_apps_path, method: :post, class: "inline", + data: { + controller: "form-values", + action: "generated-output:valueChanged@window->form-values#updateFromDisplay" + } do %> + <%= hidden_field_tag :app_name, nil, + data: { + form_values_target: "appName" + } %> + <%= hidden_field_tag :api_flag, nil, + data: { form_values_target: "apiFlag" } %> + <%= hidden_field_tag :database_choice, nil, + data: { form_values_target: "databaseChoice" } %> + <%= hidden_field_tag :rails_flags, nil, + data: { form_values_target: "railsFlags" } %> + <%= button_tag type: "submit", + id: "verify-my-setup-link", + data: { github_name_validator_target: "submitButton" }, + disabled: "disabled", + class: "flex-none inline-flex h-12 items-center bg-deep-azure-gamma rounded-lg px-6 text-gray-100 shadow-2xl opacity-50 cursor-not-allowed" do %> + 🏆️ Generate My App + <% end %> + <% end %>
@@ -60,19 +92,17 @@
@@ -84,7 +114,7 @@
<%= render Pages::Groups::Component.new(group: @page.groups.first) %>
- + diff --git a/app/views/repositories/index.html.erb b/app/views/repositories/index.html.erb deleted file mode 100644 index 2aa829f..0000000 --- a/app/views/repositories/index.html.erb +++ /dev/null @@ -1,67 +0,0 @@ -
-
-
-

Repositories

-

A list of all repositories for <%= @user.name %>

-
-
- <%= link_to new_user_repository_path(@user), - class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700" do %> - - - - New Repository - <% end %> -
-
-
-
-
-
- - - - - - - - - - <% @repositories.each do |repo| %> - - - - - - <% end %> - -
NameGitHub URLCreated
- <%= repo.name %> - - <%= link_to repo.github_url, repo.github_url, class: "text-indigo-600 hover:text-indigo-900", target: "_blank" %> - - <%= repo.created_at.strftime("%B %d, %Y") %> -
- <% if @repositories.empty? %> -
- - - -

No repositories

-

Get started by creating a new repository.

-
- <%= link_to new_user_repository_path(@user), - class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %> - - - - New Repository - <% end %> -
-
- <% end %> -
-
-
-
-
diff --git a/app/views/repositories/new.html.erb b/app/views/repositories/new.html.erb deleted file mode 100644 index 8a7d372..0000000 --- a/app/views/repositories/new.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -
-

Create New Repository

- <%= form_with(model: [ @user, @repository ], class: "space-y-4") do |f| %> -
- <%= f.label :name, class: "block text-sm font-medium text-gray-700" %> - <%= f.text_field :name, - class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm", - data: { - repository_name_validator_target: "input", - action: "input->repository-name-validator#validate" - } %> -
- - -
-
- <%= f.submit "Create Repository", class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> - <% end %> -
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 59def3e..f6b07e1 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,10 +1,10 @@
- <%= link_to new_user_repository_path(@user), + <%= link_to page_path("basic-setup"), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %> - Create new repository + Create new app <% end %> <%= link_to dashboard_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" do %> diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index feb2227..57a5559 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -4UEWXd9znHiCoVzWZhiZQQ3ww4nLxno2kP+DIFy9gQWMcewpBPmAvLDx1vOu43SamUGM47wiqCsYpfZvnL+G2XXEAkQbLjZ2Z5GqRrTk2Y9loYfz2BKPPNWYu4zTO99Ok06sOWESXOo7jAWQTJql2uzM4it6V++k1rGYq+ivUXRSPn7O+1d1M/Hd6865SjZgL2g9xCpFMakNnvygcYuYZ31q0WAMPHEvFHFmu5tPvuj4UNtnz8xb60Ij45FDFB0cBpTe6SB7PaNI2f6XLOVqID8qBBYB0ZT1mLwFQUTxfMzhKi5oJ0m9hJ6XzQ3br/BK1ahA4lMJ2OaFne/NM5klYqpvh4U859+aFhAx5/ogEzynC31O5yglUSmVXs2gCMGT11brDT84ElU9VA8jkkci9APdStLN0mTesLIFkMHExHum/Iy0fyKqqb6WLgAaE7SSGYW0YGgmaVYYvXDjvwOfSL0RbaLkkMuiOnI71cq9I5cjlFVcJAo2ZJHxNwvw+vwtmTaYwXGYAlc3d0wpPzoDaKPL9sRzE6zZUIBQwbh+0keIEsKxdSWyE7AFpfTCxdtkBUsbREkXrNTtU6oPsahmmw==--xRBi1g7Uq9Z1xCIP--8xTsLp29V+xfzbjCKRFKhQ== \ No newline at end of file +1jXsG5Ciqgbqqb9+NZrJSDx4TkHbLJE7fM4sW/FePGbgYK4rZWnhbCsjU1wCKxm8a7bhP/McJiGVVf/JcZzZYIwARou2DMEsEJpLQhrIJPvplOUjTp52NgW0q3jtjdYdZJigrKIyQNJniEAXaL3WloKedRPlRoLjioZ4ajSyVAo9FwsnLG15Tx9I6kd8+mXCD11yoRLtoBODMI0ZMJS4twKxBjQ0L+TV4/0lXNoAE3eh68ape0gEsfot9v7VxRYjyWHfWH07Ig/rSZyeWmnHYkgXbQGElyMxkSaYTbYHGYz1OVCgGQsgIZ6HoCbZJsLXdrl7IIjTI11PxRsv4sWaGyat37LcOYXh8szQil1BI6y/hdkvfYwc/waAvUqfGXjFmx+pOa1aqwg+zkL5uXSs55YbKz3L9ll7bvzF7ak4edlWBAdBegLaKKrtzMnkGysdCARZkoRUcnYbPjX5YxZTJzU0+yEuzYPEEih9xmYDfvip3+pnW6/IubEa53GQjQ7GhyA+RUHDlhfcKzHZVATLYQhwlxnvQgZ3WxvSRfD+d26mrQFLjHkaTjD7/lGvxi4BVIbIENQ00MTQ0WYVTcEfWHLTeeEIMW1m7DCyk8Vq6esujM+9G1ax9+X3yfIOSC4OFGGem3nEvsAhRFDSLmqYdpKR4Hoyy+MwHVap+upQISw5Vg==--tOwAx02FxcaihWwf--zfFLQvh7hGe2FptPcgSrVA== \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 75ac041..08043f0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -59,6 +59,7 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + config.active_record.encryption.encrypt_fixtures = true # Configure Active Record Encryption config.active_record.encryption.primary_key = "test" * 8 diff --git a/config/initializers/rails_new_config.rb b/config/initializers/rails_new_config.rb new file mode 100644 index 0000000..afad0e5 --- /dev/null +++ b/config/initializers/rails_new_config.rb @@ -0,0 +1,18 @@ +# Centralized configuration for rails new defaults +module RailsNewConfig + mattr_accessor :ruby_version, :rails_version + + # These will be updated when new versions are released + self.ruby_version = "3.3.5" + self.rails_version = "8.0.0.1" + + class << self + def ruby_version_for_new_apps + ruby_version + end + + def rails_version_for_new_apps + rails_version + end + end +end diff --git a/config/routes.rb b/config/routes.rb index a0b5cdb..4624c55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,14 +22,12 @@ resources :notifications, only: [ :index, :update ] - resources :generated_apps, only: [ :show ] do + resources :generated_apps, only: [ :show, :create ] do resources :generation_attempts, only: [ :create ] resources :log_entries, only: [ :index ] end - resources :users, only: [ :show ], path: "" do - resources :repositories, only: [ :new, :create, :show, :index ] - end + resources :users, only: [ :show ], path: "" resources :pages, only: :show @@ -40,5 +38,5 @@ root to: "static#home" # named routes - get "/repositories/check_name", to: "repositories#check_name", as: :check_repository_name + get "/github/check_name", to: "github#check_name", as: :check_github_name end diff --git a/db/migrate/20241215164138_drop_repositories.rb b/db/migrate/20241215164138_drop_repositories.rb new file mode 100644 index 0000000..ed72cce --- /dev/null +++ b/db/migrate/20241215164138_drop_repositories.rb @@ -0,0 +1,18 @@ +class DropRepositories < ActiveRecord::Migration[8.0] + def up + drop_table :repositories + end + + def down + create_table :repositories do |t| + t.string :name, null: false + t.string :github_url, null: false + t.references :user, null: false, foreign_key: true, index: true + + t.timestamps + + t.index :name + t.index :github_url, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index eda8b35..960f600 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_12_12_031732) do +ActiveRecord::Schema[8.0].define(version: 2024_12_15_164138) do create_table "_litestream_lock", id: false, force: :cascade do |t| t.integer "id" end @@ -292,17 +292,6 @@ t.index ["created_by_id"], name: "index_recipes_on_created_by_id" end - create_table "repositories", force: :cascade do |t| - t.string "name", null: false - t.string "github_url", null: false - t.integer "user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["github_url"], name: "index_repositories_on_github_url", unique: true - t.index ["name"], name: "index_repositories_on_name" - t.index ["user_id"], name: "index_repositories_on_user_id" - end - create_table "solid_queue_blocked_executions", force: :cascade do |t| t.integer "job_id", null: false t.string "queue_name", null: false @@ -466,7 +455,6 @@ add_foreign_key "recipe_ingredients", "ingredients" add_foreign_key "recipe_ingredients", "recipes" add_foreign_key "recipes", "users", column: "created_by_id" - add_foreign_key "repositories", "users" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/test/components/github_auth_button_component_test.rb b/test/components/github_auth_button_component_test.rb index c9cf316..72eae93 100644 --- a/test/components/github_auth_button_component_test.rb +++ b/test/components/github_auth_button_component_test.rb @@ -13,12 +13,7 @@ class GithubAuthButton::ComponentTest < PhlexComponentTestCase end test "renders logout button when user is logged in" do - user = User.create!( - name: "Test User", - provider: "github", - uid: "123456", - github_username: "testuser" - ) + user = users(:john) Current.stub(:user, user) do component = GithubAuthButton::Component.new diff --git a/test/controllers/generated_apps_controller_test.rb b/test/controllers/generated_apps_controller_test.rb index c026687..8faed86 100644 --- a/test/controllers/generated_apps_controller_test.rb +++ b/test/controllers/generated_apps_controller_test.rb @@ -1,26 +1,86 @@ require "test_helper" +require_relative "../support/git_test_helper" class GeneratedAppsControllerTest < ActionDispatch::IntegrationTest + include GitTestHelper + setup do @user = users(:jane) - @generated_app = generated_apps(:blog_app) - sign_in @user + sign_in(@user) end test "should show generated app" do - get generated_app_url(@generated_app) + get generated_app_url(generated_apps(:blog_app)) assert_response :success + end + + test "requires authentication" do + sign_out(@user) + post generated_apps_path, params: { app_name: "test-app" } + assert_redirected_to root_path + assert_equal "Please sign in first.", flash[:alert] + end + + test "creates app with valid parameters" do + app_name = "my-test-app" + api_flag = "--api" + database = "--database=mysql" + + Recipe.any_instance.stubs(:commit_changes).returns(true) + Recipe.any_instance.stubs(:initial_git_commit).returns(true) + GeneratedApp.any_instance.stubs(:commit_changes).returns(true) + GeneratedApp.any_instance.stubs(:initial_git_commit).returns(true) + + assert_difference "GeneratedApp.count" do + assert_difference "Recipe.count" do + post generated_apps_path, params: { + app_name: app_name, + api_flag: api_flag, + database_choice: database + } + end + end + + app = GeneratedApp.last + assert_equal app_name, app.name + assert_equal @user, app.user + assert_equal "#{api_flag} #{database}", app.recipe.cli_flags - assert_select "h1", @generated_app.name - assert_select "p", @generated_app.description - assert_select "p", @generated_app.ruby_version - assert_select "p", @generated_app.rails_version - assert_select "a[href=?]", @generated_app.github_repo_url + assert_redirected_to generated_app_log_entries_path(app) end - test "should not show generated app for unauthorized user" do - sign_out @user - get generated_app_url(@generated_app) - assert_redirected_to root_url + test "reuses existing recipe if cli flags match" do + recipe = recipes(:api_recipe) # Has "--api --database=postgresql" flags + + GeneratedApp.any_instance.stubs(:commit_changes).returns(true) + GeneratedApp.any_instance.stubs(:initial_git_commit).returns(true) + + assert_difference "GeneratedApp.count" do + assert_no_difference "Recipe.count" do + post generated_apps_path, params: { + app_name: "new-api", + api_flag: "--api", + database_choice: "--database=postgresql" + } + end + end + + app = GeneratedApp.last + assert_equal recipe, app.recipe + end + + test "starts app generation after creation" do + Recipe.any_instance.stubs(:commit_changes).returns(true) + Recipe.any_instance.stubs(:initial_git_commit).returns(true) + GeneratedApp.any_instance.stubs(:commit_changes).returns(true) + GeneratedApp.any_instance.stubs(:initial_git_commit).returns(true) + + AppGeneration::Orchestrator.any_instance.expects(:call) + + post generated_apps_path, params: { + app_name: "test-app", + api_flag: "--api", + database_choice: "--database=mysql" + } end end diff --git a/test/controllers/github_controller_test.rb b/test/controllers/github_controller_test.rb new file mode 100644 index 0000000..7a5b94b --- /dev/null +++ b/test/controllers/github_controller_test.rb @@ -0,0 +1,110 @@ +require "test_helper" + +class GithubControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:jane) + @user.stubs(:github_username).returns("jane_smith") + sign_in(@user) + end + + test "returns success when repository does not exist" do + validator = mock("validator") + validator.expects(:repo_exists?).returns(false) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + get check_github_name_path, params: { name: "test-repo" } + + assert_response :success + assert_equal({ "available" => true }, response.parsed_body) + end + + test "returns success when repository exists" do + validator = mock("validator") + validator.expects(:repo_exists?).returns(true) + GithubRepositoryNameValidator.expects(:new) + .with("existing-repo", "jane_smith") + .returns(validator) + + get check_github_name_path, params: { name: "existing-repo" } + + assert_response :success + assert_equal({ "available" => false }, response.parsed_body) + end + + test "handles unauthorized GitHub access" do + validator = mock("validator") + validator.expects(:repo_exists?).raises(Octokit::Unauthorized) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + get check_github_name_path, params: { name: "test-repo" } + + assert_response :unauthorized + assert_equal({ "error" => "GitHub authentication failed" }, response.parsed_body) + end + + test "handles forbidden GitHub access" do + validator = mock("validator") + validator.expects(:repo_exists?).raises(Octokit::Forbidden) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + get check_github_name_path, params: { name: "test-repo" } + + assert_response :unauthorized + assert_equal({ "error" => "GitHub authentication failed" }, response.parsed_body) + end + + test "handles other GitHub errors" do + validator = mock("validator") + validator.expects(:repo_exists?).raises(Octokit::Error) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + get check_github_name_path, params: { name: "test-repo" } + + assert_response :unprocessable_entity + assert_equal({ "error" => "Could not validate repository name" }, response.parsed_body) + end + + test "requires authentication" do + sign_out(@user) + + get check_github_name_path, params: { name: "test-repo" } + + assert_response :redirect + assert_redirected_to root_path + assert_equal "Please sign in first.", flash[:alert] + end + + test "logs errors when GitHub authentication fails" do + validator = mock("validator") + error = Octokit::Unauthorized.new + validator.expects(:repo_exists?).raises(error) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + Rails.logger.expects(:error).with("GitHub authentication error: #{error.message}") + + get check_github_name_path, params: { name: "test-repo" } + end + + test "logs errors for other GitHub validation failures" do + validator = mock("validator") + error = Octokit::Error.new + validator.expects(:repo_exists?).raises(error) + GithubRepositoryNameValidator.expects(:new) + .with("test-repo", "jane_smith") + .returns(validator) + + Rails.logger.expects(:error).with("GitHub validation error: #{error.message}") + + get check_github_name_path, params: { name: "test-repo" } + end +end diff --git a/test/controllers/repositories_controller_test.rb b/test/controllers/repositories_controller_test.rb deleted file mode 100644 index 73fda81..0000000 --- a/test/controllers/repositories_controller_test.rb +++ /dev/null @@ -1,75 +0,0 @@ -require "test_helper" - -class RepositoriesControllerTest < ActionDispatch::IntegrationTest - def setup - @user = users(:john) - sign_in @user - end - - test "should get index" do - get user_repositories_path(@user) - assert_response :success - end - - test "should get new" do - get new_user_repository_path(@user) - assert_response :success - end - - test "check_name returns true for valid repository name" do - validator = mock - validator.expects(:valid?).returns(true) - GithubRepositoryNameValidator.expects(:new) - .with("test-repo", @user.github_username) - .returns(validator) - - get check_repository_name_path, params: { name: "test-repo" } - - assert_response :success - assert_equal({ "available" => true }, JSON.parse(response.body)) - end - - test "should create repository" do - repository = Repository.new( - name: "test-repo", - github_url: "https://github.com/johndoe/test-repo", - user: @user - ) - - service_mock = Minitest::Mock.new - service_mock.expect :create_repository, repository do |name| - repository.save! - true - end - - GithubRepositoryService.stub :new, service_mock do - assert_difference("Repository.count") do - post user_repositories_path(@user), params: { repository: { name: "test-repo" } } - end - end - - assert_redirected_to user_repositories_path(@user) - assert_equal "Repository created successfully!", flash[:notice] - end - - test "should handle repository creation error" do - service_mock = Minitest::Mock.new - service_mock.expect :create_repository, nil do |_name| - raise GithubRepositoryService::Error, "API error" - end - - GithubRepositoryService.stub :new, service_mock do - post user_repositories_path(@user), params: { repository: { name: "test-repo" } } - end - - assert_redirected_to new_user_repository_path(@user) - assert_equal "API error", flash[:alert] - end - - test "should redirect to root if not authenticated" do - sign_out @user - get user_repositories_path(@user) - assert_redirected_to root_path - assert_equal "Please sign in with GitHub first!", flash[:alert] - end -end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 80da261..3869fcc 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -32,21 +32,22 @@ class SessionControllerTest < ActionDispatch::IntegrationTest test "successful github sign_in" do + user = users(:john) auth_hash = OmniAuth::AuthHash.new({ provider: "github", - uid: "123545", + uid: user.uid, info: { - name: "Test User", - email: "test@example.com", - nickname: "testuser", - image: "http://example.com/image.jpg" + name: user.name, + email: user.email, + nickname: user.github_username, + image: user.image }, credentials: { token: "mock_token" }, extra: { raw_info: { - login: "testuser" + login: user.github_username } } }) @@ -54,16 +55,12 @@ class SessionControllerTest < ActionDispatch::IntegrationTest OmniAuth.config.test_mode = true OmniAuth.config.mock_auth[:github] = auth_hash - # Set up the omniauth.auth environment get "/auth/github/callback", env: { 'omniauth.auth': auth_hash } assert_response :redirect assert_redirected_to dashboard_url - - user = User.find_by(email: "test@example.com") - assert user.present? - assert_equal session[:user_id], user.id - assert_equal "Logged in as Test User", flash[:notice] + assert_equal user.id, session[:user_id] + assert_equal "Logged in as #{user.name}", flash[:notice] end test "github oauth failure" do @@ -135,7 +132,6 @@ class SessionControllerTest < ActionDispatch::IntegrationTest Recipe.delete_all # Then recipes Ingredient.delete_all # Then ingredients Noticed::Notification.delete_all # Then notifications - Repository.delete_all # Then repositories User.delete_all # Finally users silence_omniauth_logger do diff --git a/test/fixtures/generated_apps.yml b/test/fixtures/generated_apps.yml index b7bc82d..4b8b6a7 100644 --- a/test/fixtures/generated_apps.yml +++ b/test/fixtures/generated_apps.yml @@ -78,7 +78,7 @@ saas_starter: database: "postgresql" css: "tailwind" testing: "minitest" - source_path: <%= Rails.root.join("tmp", "test_apps", "saas_starter") %> + source_path: <%= Rails.root.join("tmp", "test_saas_starter") %> github_repo_url: "https://github.com/johndoe/saas-starter" github_repo_name: "saas-starter" is_public: false diff --git a/test/fixtures/ingredients.yml b/test/fixtures/ingredients.yml index ed9ef47..8b9a682 100644 --- a/test/fixtures/ingredients.yml +++ b/test/fixtures/ingredients.yml @@ -49,14 +49,14 @@ api_setup: category: "api" basic: - name: "Basic Rails Setup" - description: "Basic Rails application setup with common configurations" - template_content: | - # Basic Rails setup - gem 'bootsnap' - gem 'puma' - configures_with: {} + name: "Basic Rails" + description: "A basic Rails setup" + template_content: "# Basic Rails template" conflicts_with: [] requires: [] + configures_with: + database: + - postgresql + - mysql created_by: john category: "setup" diff --git a/test/fixtures/repositories.yml b/test/fixtures/repositories.yml deleted file mode 100644 index c06b708..0000000 --- a/test/fixtures/repositories.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -# == Schema Information -# -# Table name: repositories -# -# id :integer not null, primary key -# github_url :string not null -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# user_id :integer not null -# -# Indexes -# -# index_repositories_on_github_url (github_url) UNIQUE -# index_repositories_on_name (name) -# index_repositories_on_user_id (user_id) -# -# Foreign Keys -# -# user_id (user_id => users.id) -# -one: - name: repo-one - github_url: https://github.com/johndoe/repo-one - user: john - -two: - name: repo-two - github_url: https://github.com/johndoe/repo-two - user: john - -three: - name: repo-three - github_url: https://github.com/jane_smith/repo-three - user: jane diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index ae82002..3729e65 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -23,25 +23,25 @@ # john: - name: John Doe - email: john@example.com + name: "John Doe" + email: "john@example.com" image: https://github.com/images/john.jpg - provider: github - uid: "92839283" + provider: "github" + uid: "123456" slug: john-doe - github_username: johndoe + github_username: "johndoe" github_token: "fake-token" created_at: <%= Time.current %> updated_at: <%= Time.current %> jane: - name: Jane Smith + name: "Jane Smith" email: "jane@example.com" image: https://github.com/images/jane.jpg - provider: github - uid: '789012' + provider: "github" + uid: "789012" slug: jane-smith - github_username: jane_smith + github_username: "jane_smith" github_token: "fake-token" created_at: <%= Time.current %> updated_at: <%= Time.current %> diff --git a/test/models/app_change_test.rb b/test/models/app_change_test.rb index 15b7e2f..2030c1d 100644 --- a/test/models/app_change_test.rb +++ b/test/models/app_change_test.rb @@ -158,6 +158,18 @@ class AppChangeTest < ActiveSupport::TestCase end end + test "to_git_format includes recipe change type" do + app_change = app_changes(:blog_auth_change) # Use existing fixture + + git_format = app_change.to_git_format + + assert_equal app_change.recipe_change.change_type, git_format[:recipe_change_type] + assert_equal app_change.configuration, git_format[:configuration] + assert_nil git_format[:applied_at], "Expected applied_at to be nil for unapplied change" + assert_equal false, git_format[:success], "Expected success to be false for unapplied change" + assert_nil git_format[:error_message], "Expected error_message to be nil for unapplied change" + end + private def mock_popen3(stdout, stderr, success: true, pid: 12345) diff --git a/test/models/recipe_test.rb b/test/models/recipe_test.rb index bea3dfc..fb14d47 100644 --- a/test/models/recipe_test.rb +++ b/test/models/recipe_test.rb @@ -186,4 +186,29 @@ class RecipeTest < ActiveSupport::TestCase assert_equal [ 1, 2 ], @recipe.recipe_ingredients.order(:position).pluck(:position) end + + test "find_or_create_by_cli_flags! finds existing recipe" do + existing = recipes(:api_recipe) + recipe = Recipe.find_or_create_by_cli_flags!(existing.cli_flags, @user) + + assert_equal existing, recipe + end + + test "find_or_create_by_cli_flags! creates new recipe when none exists" do + cli_flags = "--api --minimal" + + Recipe.any_instance.stubs(:commit_changes).returns(true) + Recipe.any_instance.stubs(:initial_git_commit).returns(true) + GitRepo.any_instance.stubs(:commit_changes).returns(true) + GitRepo.any_instance.stubs(:write_model).returns(true) + + assert_difference "Recipe.count", 1 do + recipe = Recipe.find_or_create_by_cli_flags!(cli_flags, @user) + + assert_equal cli_flags, recipe.cli_flags + assert_equal "Rails App with #{cli_flags}", recipe.name + assert_equal "published", recipe.status + assert_equal @user, recipe.created_by + end + end end diff --git a/test/models/repository_test.rb b/test/models/repository_test.rb deleted file mode 100644 index 7132fde..0000000 --- a/test/models/repository_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -# == Schema Information -# -# Table name: repositories -# -# id :integer not null, primary key -# github_url :string not null -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# user_id :integer not null -# -# Indexes -# -# index_repositories_on_github_url (github_url) UNIQUE -# index_repositories_on_name (name) -# index_repositories_on_user_id (user_id) -# -# Foreign Keys -# -# user_id (user_id => users.id) -# -require "test_helper" - -class RepositoryTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/services/app_generation/orchestrator_test.rb b/test/services/app_generation/orchestrator_test.rb new file mode 100644 index 0000000..96d3d61 --- /dev/null +++ b/test/services/app_generation/orchestrator_test.rb @@ -0,0 +1,56 @@ +require "test_helper" +require_relative "../../../app/services/app_generation/errors" + +module AppGeneration + class OrchestratorTest < ActiveSupport::TestCase + setup do + @generated_app = generated_apps(:pending_app) + @orchestrator = Orchestrator.new(@generated_app) + end + + test "enqueues generation job when app is in pending state" do + assert @generated_app.pending? + + assert_difference -> { SolidQueue::Job.count } do + assert @orchestrator.call + end + + job = SolidQueue::Job.last + assert_equal "AppGenerationJob", job.class_name + assert_equal [ @generated_app.id ], job.arguments["arguments"] + end + + test "validates app must be in pending state" do + @generated_app.app_status.update!(status: "generating") + @generated_app.reload + + assert_equal "generating", @generated_app.status + assert_not @generated_app.pending? + + error = assert_raises(AppGeneration::Errors::InvalidStateError) do + @orchestrator.call + end + + assert_equal "App must be in pending state to start generation", error.message + end + + test "handles and logs errors during orchestration" do + error_message = "Something went wrong" + AppGenerationJob.stubs(:perform_later).raises(StandardError.new(error_message)) + + # Expect both error logs in sequence + sequence = sequence("error_logging") + AppGeneration::Logger.any_instance.expects(:error).with( + "Failed to start app generation", + { error: error_message } + ).in_sequence(sequence) + AppGeneration::Logger.any_instance.expects(:error).with( + "App generation failed: #{error_message}" + ).in_sequence(sequence) + + assert_not @orchestrator.call + assert @generated_app.reload.failed? + assert_equal error_message, @generated_app.app_status.error_message + end + end +end diff --git a/test/services/data_repository_test.rb b/test/services/data_repository_test.rb new file mode 100644 index 0000000..2f93ae1 --- /dev/null +++ b/test/services/data_repository_test.rb @@ -0,0 +1,293 @@ +require "test_helper" + +class DataRepositoryTest < ActiveSupport::TestCase + fixtures :users, :recipes, :ingredients, :generated_apps + + def setup + @user = users(:john) + @repo = DataRepository.new(user: @user) + @git_mock = mock("git") + @repo.stubs(:git).returns(@git_mock) + @git_mock.stubs(:fetch) + @git_mock.stubs(:reset_hard) + @git_mock.stubs(:pull) + @git_mock.stubs(:push) + end + + # Class method tests + def test_name_for_environment_in_development + Rails.env.stubs(:development?).returns(true) + Rails.env.stubs(:test?).returns(false) + Rails.env.stubs(:production?).returns(false) + + assert_equal "rails-new-io-data-dev", DataRepository.name_for_environment + end + + def test_name_for_environment_in_test + Rails.env.stubs(:development?).returns(false) + Rails.env.stubs(:test?).returns(true) + Rails.env.stubs(:production?).returns(false) + + assert_equal "rails-new-io-data-test", DataRepository.name_for_environment + end + + def test_name_for_environment_in_production + Rails.env.stubs(:development?).returns(false) + Rails.env.stubs(:test?).returns(false) + Rails.env.stubs(:production?).returns(true) + + assert_equal "rails-new-io-data", DataRepository.name_for_environment + end + + def test_name_for_environment_raises_error_for_unknown_environment + Rails.env.stubs(:development?).returns(false) + Rails.env.stubs(:test?).returns(false) + Rails.env.stubs(:production?).returns(false) + + assert_raises(ArgumentError) { DataRepository.name_for_environment } + end + + # Instance method tests + def test_writes_ingredient_correctly + ingredient = ingredients(:rails_authentication) + base_path = File.join(@repo.send(:repo_path), "ingredients", ingredient.name.parameterize) + + # Expect template.rb write + template_path = File.join(base_path, "template.rb") + File.expects(:write).with(template_path, ingredient.template_content) + + # Expect metadata.json write + metadata_path = File.join(base_path, "metadata.json") + metadata_content = { + name: ingredient.name, + description: ingredient.description, + conflicts_with: ingredient.conflicts_with, + requires: ingredient.requires, + configures_with: ingredient.configures_with + }.to_json + File.expects(:write).with(metadata_path, metadata_content) + + FileUtils.stubs(:mkdir_p) + @repo.stubs(:ensure_fresh_repo) + @repo.stubs(:push_to_remote) + + @repo.write_model(ingredient) + end + + def test_writes_recipe_correctly + recipe = recipes(:blog_recipe) + base_path = File.join(@repo.send(:repo_path), "recipes", recipe.id.to_s) + + # Expect manifest.json write + manifest_path = File.join(base_path, "manifest.json") + manifest_content = { + name: recipe.name, + cli_flags: recipe.cli_flags, + ruby_version: recipe.ruby_version, + rails_version: recipe.rails_version + }.to_json + File.expects(:write).with(manifest_path, manifest_content) + + # Expect ingredients.json write + ingredients_path = File.join(base_path, "ingredients.json") + ingredients_content = recipe.recipe_ingredients.order(:position).map(&:to_git_format).to_json + File.expects(:write).with(ingredients_path, ingredients_content) + + FileUtils.stubs(:mkdir_p) + @repo.stubs(:ensure_fresh_repo) + @repo.stubs(:push_to_remote) + + @repo.write_model(recipe) + end + + def test_raises_error_when_trying_to_write_generated_app + app = generated_apps(:blog_app) + + # Mock git branches and status + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git_mock.stubs(:branches).returns(branches_collection) + + # Mock git status + status_mock = mock("status") + status_mock.stubs(:changed).returns({}) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git_mock.stubs(:status).returns(status_mock) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git_mock.stubs(:branch).returns(current_branch_mock) + + # Mock git operations that happen in ensure_fresh_repo + @git_mock.stubs(:add).with(all: true) + @git_mock.stubs(:commit).with("Initialize repository structure") + + # Stub filesystem operations + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:touch) + File.stubs(:directory?).returns(false) # This triggers ensure_committable_state + File.stubs(:write) + + assert_raises(NotImplementedError, "Generated apps are stored in their own repositories") do + @repo.write_model(app) + end + end + + def test_handles_git_push_error + recipe = recipes(:blog_recipe) + base_path = File.join(@repo.send(:repo_path), "recipes", recipe.id.to_s) + + # Stub the file writes + manifest_path = File.join(base_path, "manifest.json") + ingredients_path = File.join(base_path, "ingredients.json") + File.stubs(:write).with(manifest_path, anything) + File.stubs(:write).with(ingredients_path, anything) + + FileUtils.stubs(:mkdir_p) + @repo.stubs(:ensure_fresh_repo) + @repo.unstub(:push_to_remote) # We want the real push_to_remote to trigger the error + @git_mock.stubs(:push).raises(Git::Error.new("Push failed")) + + assert_raises(GitRepo::GitSyncError) { @repo.write_model(recipe) } + end + + def test_ensure_committable_state_creates_required_structure + path = @repo.send(:repo_path) + + # Expect directory creation for each required directory + %w[ingredients recipes].each do |dir| + dir_path = File.join(path, dir) + FileUtils.expects(:mkdir_p).with(dir_path) + FileUtils.expects(:touch).with(File.join(dir_path, ".keep")) + end + + # Expect README.md creation + readme_path = File.join(path, "README.md") + File.expects(:write).with(readme_path, "# Data Repository\nThis repository contains data for rails-new.io") + + @repo.send(:ensure_committable_state) + end + + def test_ensure_fresh_repo_syncs_with_remote + sequence = sequence("git_sync") + + # Mock git branches and status + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git_mock.stubs(:branches).returns(branches_collection) + + # Mock git status + status_mock = mock("status") + status_mock.stubs(:changed).returns({}) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git_mock.stubs(:status).returns(status_mock) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git_mock.stubs(:branch).returns(current_branch_mock) + + # Set up sequence of operations + @git_mock.expects(:fetch).in_sequence(sequence) + @git_mock.expects(:reset_hard).with("origin/main").in_sequence(sequence) + @git_mock.expects(:pull).in_sequence(sequence) + + # Mock directory check to avoid initialization + File.stubs(:directory?).returns(true) + + @repo.send(:ensure_fresh_repo) + end + + def test_repository_description_is_specific_to_data_repo + assert_equal "Data repository for rails-new.io", @repo.send(:repository_description) + end + + def test_ensure_fresh_repo_commits_and_pushes_when_changes_exist + # Mock git branches and status + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git_mock.stubs(:branches).returns(branches_collection) + + # Mock git status to indicate changes + status_mock = mock("status") + status_mock.stubs(:changed).returns({ "file1" => "modified" }) # Has changes + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git_mock.stubs(:status).returns(status_mock) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git_mock.stubs(:branch).returns(current_branch_mock) + + # Mock filesystem operations + FileUtils.stubs(:mkdir_p).returns(true) # Return true for all mkdir_p calls + FileUtils.stubs(:touch).returns(true) # Return true for all touch calls + File.stubs(:directory?).returns(false) # Trigger initialization + File.stubs(:write).returns(true) # Allow README.md creation + + # Expect git operations + @git_mock.expects(:add).with(all: true) + @git_mock.expects(:commit).with("Initialize repository structure") + @git_mock.expects(:push).with("origin", "main") + + @repo.send(:ensure_fresh_repo) + end + + def test_ensure_fresh_repo_skips_commit_when_no_changes + # Mock git branches and status + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git_mock.stubs(:branches).returns(branches_collection) + + # Mock git status to indicate no changes + status_mock = mock("status") + status_mock.stubs(:changed).returns({}) # No changes + status_mock.stubs(:added).returns({}) # No additions + status_mock.stubs(:deleted).returns({}) + @git_mock.stubs(:status).returns(status_mock) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git_mock.stubs(:branch).returns(current_branch_mock) + + # Mock filesystem operations + FileUtils.stubs(:mkdir_p).returns(true) # Return true for all mkdir_p calls + FileUtils.stubs(:touch).returns(true) # Return true for all touch calls + File.stubs(:directory?).returns(false) # Trigger initialization + File.stubs(:write).returns(true) # Allow README.md creation + + # Expect git operations + @git_mock.expects(:add).with(all: true) # add is still called + @git_mock.expects(:commit).never # but commit should never happen + @git_mock.expects(:push).never # and push should never happen + + @repo.send(:ensure_fresh_repo) + end + + private + + def stub_filesystem_operations + @repo.stubs(:ensure_fresh_repo) + @repo.stubs(:push_to_remote) + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:touch) + end + + def assert_path_written(relative_path, expected_content) + full_path = File.join(@repo.send(:repo_path), relative_path) + assert File.stubs(:write).with(full_path, expected_content.to_json).once + end +end diff --git a/test/services/git_repo_clone_test.rb b/test/services/git_repo_clone_test.rb new file mode 100644 index 0000000..d246b81 --- /dev/null +++ b/test/services/git_repo_clone_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class GitRepoCloneTest < ActiveSupport::TestCase + setup do + @user = users(:jane) + @user.stubs(:name).returns("Jane Smith") + @user.stubs(:email).returns("jane@example.com") + + @repo_name = "rails-new-io-data-test" + @repo_path = Rails.root.join("tmp", "git_repos", @user.id.to_s, @repo_name) + + # Stub file operations + File.stubs(:write).returns(true) + File.stubs(:exist?).returns(true) + File.stubs(:read).returns("# Repository\nCreated via railsnew.io") + Dir.stubs(:glob).returns([]) + + # Initialize Octokit client mock + @mock_client = mock("octokit_client") + Octokit::Client.stubs(:new).returns(@mock_client) + + # Stub all FileUtils operations globally + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:rm_rf) + + @repo = GitRepo.new(user: @user, repo_name: @repo_name) + end + + test "clones repository when remote exists but no local copy" do + File.stubs(:exist?).with(@repo_path).returns(false) + @mock_client.stubs(:repository?).returns(true) + + # Mock git status + status_mock = mock("status") + status_mock.stubs(:changed).returns({ "file1" => "modified" }) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + + # Mock git branches + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + + # Set up cloned git mock with all required stubs + cloned_git = mock("cloned_git") + cloned_git.stubs(:branch).returns(current_branch_mock) + cloned_git.stubs(:branches).returns(branches_collection) + cloned_git.stubs(:status).returns(status_mock) + + # Track operations + operations = [] + cloned_git.expects(:config).with("user.name", "Jane Smith").tap { operations << :config_name } + cloned_git.expects(:config).with("user.email", "jane@example.com").tap { operations << :config_email } + cloned_git.expects(:add).with(all: true).tap { operations << :add } + cloned_git.expects(:commit).with("Test commit").tap { operations << :commit } + cloned_git.expects(:push).with("origin", "main").tap { operations << :push } + + Git.expects(:clone).with( + "https://fake-token@github.com/#{@user.github_username}/#{@repo_name}.git", + @repo_name, + path: File.dirname(@repo_path) + ) + Git.expects(:open).with(@repo_path).returns(cloned_git) + + @repo.commit_changes(message: "Test commit", author: @user) + + # Assert operations happened in correct order + expected_operations = [ :config_name, :config_email, :add, :commit, :push ] + assert_equal expected_operations, operations, "Git operations were not performed in the expected order" + end +end diff --git a/test/services/git_repo_test.rb b/test/services/git_repo_test.rb index 1e8a616..1c5e2a7 100644 --- a/test/services/git_repo_test.rb +++ b/test/services/git_repo_test.rb @@ -3,420 +3,273 @@ class GitRepoTest < ActiveSupport::TestCase setup do @user = users(:jane) - # Define github_token method to bypass encryption - @user.define_singleton_method(:github_token) { "fake-token" } + @user.stubs(:name).returns("Jane Smith") + @user.stubs(:email).returns("jane@example.com") @repo_name = "rails-new-io-data-test" @repo_path = Rails.root.join("tmp", "git_repos", @user.id.to_s, @repo_name) - # Ensure parent directory exists and is empty - FileUtils.rm_rf(File.dirname(@repo_path)) - FileUtils.mkdir_p(File.dirname(@repo_path)) - # Create a mock git object @git = mock("git") @git.stubs(:config) @git.stubs(:add) @git.stubs(:commit) + + # Create a branch mock explicitly + @branch_mock = mock("branch") + @branch_mock.stubs(:name).returns("main") + @git.stubs(:branch).returns(@branch_mock) + Git.stubs(:init).returns(@git) Git.stubs(:open).returns(@git) + Git.stubs(:clone) # Stub file operations - FileUtils.stubs(:mkdir_p).returns(true) File.stubs(:write).returns(true) File.stubs(:exist?).returns(true) - File.stubs(:read).returns("{}") - JSON.stubs(:parse).returns({}) + File.stubs(:read).returns("# Repository\nCreated via railsnew.io") + Dir.stubs(:glob).returns([]) - # Stub GitHub API calls - GitRepo.any_instance.stubs(:remote_repo_exists?).returns(false) - GitRepo.any_instance.stubs(:create_github_repo).returns(true) - GitRepo.any_instance.stubs(:create_initial_structure).returns(true) - GitRepo.any_instance.stubs(:setup_remote).returns(true) + # Initialize Octokit client mock + @mock_client = mock("octokit_client") + Octokit::Client.stubs(:new).returns(@mock_client) + + # Stub all FileUtils operations globally + FileUtils.stubs(:mkdir_p) + FileUtils.stubs(:rm_rf) @repo = GitRepo.new(user: @user, repo_name: @repo_name) end teardown do - # Clean up after each test FileUtils.rm_rf(@repo_path) if File.exist?(@repo_path) FileUtils.rm_rf(File.dirname(@repo_path)) if File.exist?(File.dirname(@repo_path)) - - # Clean up any remaining stubs - GitRepo.any_instance.unstub(:remote_repo_exists?) - GitRepo.any_instance.unstub(:create_github_repo) - GitRepo.any_instance.unstub(:create_initial_structure) - GitRepo.any_instance.unstub(:setup_remote) - Rails.unstub(:env) if Rails.respond_to?(:env) && Rails.env.is_a?(Mocha::Mock) - end - - test "initializes with user and repo name" do - assert_equal @user, @repo.instance_variable_get(:@user) - assert_equal @repo_name, @repo.instance_variable_get(:@repo_name) - expected_path = Rails.root.join("tmp", "git_repos", @user.id.to_s, @repo_name) - assert_equal expected_path, @repo.instance_variable_get(:@repo_path) - end - - test "writes generated app to repo" do - app = generated_apps(:blog_app) - path = File.join(@repo_path, "generated_apps", app.id.to_s) - - # Expect git operations - @git.expects(:fetch) - @git.expects(:reset_hard).with("origin/main") - @git.expects(:pull) - @git.expects(:push).with("origin", "main") - - # Expect file operations - FileUtils.expects(:mkdir_p).with(path) - File.expects(:write).with( - File.join(path, "current_state.json"), - JSON.pretty_generate({ - name: app.name, - recipe_id: app.recipe_id, - configuration: app.configuration_options - }) - ) - File.expects(:write).with( - File.join(path, "history.json"), - JSON.pretty_generate(app.app_changes.map(&:to_git_format)) - ) - - @repo.write_model(app) - end - - test "writes ingredient to repo" do - ingredient = ingredients(:rails_authentication) - path = File.join(@repo_path, "ingredients", ingredient.name.parameterize) - - # Expect git operations - @git.expects(:fetch) - @git.expects(:reset_hard).with("origin/main") - @git.expects(:pull) - @git.expects(:push).with("origin", "main") - - # Expect file operations - FileUtils.expects(:mkdir_p).with(path) - File.expects(:write).with( - File.join(path, "template.rb"), - ingredient.template_content - ) - File.expects(:write).with( - File.join(path, "metadata.json"), - JSON.pretty_generate({ - name: ingredient.name, - description: ingredient.description, - conflicts_with: ingredient.conflicts_with, - requires: ingredient.requires, - configures_with: ingredient.configures_with - }) - ) - - @repo.write_model(ingredient) end - test "writes recipe to repo" do - recipe = recipes(:blog_recipe) - path = File.join(@repo_path, "recipes", recipe.id.to_s) - - # Expect git operations - @git.expects(:fetch) - @git.expects(:reset_hard).with("origin/main") - @git.expects(:pull) - @git.expects(:push).with("origin", "main") - - # Expect file operations - FileUtils.expects(:mkdir_p).with(path) - File.expects(:write).with( - File.join(path, "manifest.json"), - JSON.pretty_generate({ - name: recipe.name, - cli_flags: recipe.cli_flags, - ruby_version: recipe.ruby_version, - rails_version: recipe.rails_version - }) - ) - File.expects(:write).with( - File.join(path, "ingredients.json"), - JSON.pretty_generate(recipe.recipe_ingredients.order(:position).map(&:to_git_format)) - ) - - @repo.write_model(recipe) + test "commits changes to existing local repository" do + File.stubs(:exist?).with(@repo_path).returns(true) + File.stubs(:exist?).with(File.join(@repo_path, ".git")).returns(true) + + # Create a mock status object + status_mock = mock("status") + status_mock.stubs(:changed).returns({ "file1" => "modified" }) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git.stubs(:status).returns(status_mock) + + # Mock branches collection + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git.stubs(:branches).returns(branches_collection) + + # Mock the current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git.stubs(:branch).returns(current_branch_mock) + + # Track the operations performed + operations = [] + @git.expects(:fetch).tap { operations << :fetch } + @git.expects(:reset_hard).with("origin/main").tap { operations << :reset } + @git.expects(:config).with("user.name", "Jane Smith").tap { operations << :config_name } + @git.expects(:config).with("user.email", "jane@example.com").tap { operations << :config_email } + @git.expects(:add).with(all: true).tap { operations << :add } + @git.expects(:commit).with("Test commit").tap { operations << :commit } + @git.expects(:push).with("origin", "main").tap { operations << :push } + + @mock_client.expects(:repository?).with("#{@user.github_username}/#{@repo_name}").returns(true) + + @repo.commit_changes(message: "Test commit", author: @user) + + # Assert all operations were performed in the correct order + expected_operations = [ :fetch, :reset, :config_name, :config_email, :add, :commit, :push ] + assert_equal expected_operations, operations, "Git operations were not performed in the expected order" + + # Assert repository state + assert File.exist?(@repo_path), "Repository directory does not exist" + assert File.exist?(File.join(@repo_path, ".git")), "Git directory does not exist" end - test "handles git push errors" do - recipe = recipes(:blog_recipe) - path = File.join(@repo_path, "recipes", recipe.id.to_s) - - # Expect git operations - @git.expects(:fetch) - @git.expects(:reset_hard).with("origin/main") - @git.expects(:pull) - @git.expects(:push).with("origin", "main").raises(Git::Error.new("push failed")) - - # Expect file operations (these need to succeed before the push fails) - FileUtils.expects(:mkdir_p).with(path) - File.expects(:write).with( - File.join(path, "manifest.json"), - JSON.pretty_generate({ - name: recipe.name, - cli_flags: recipe.cli_flags, - ruby_version: recipe.ruby_version, - rails_version: recipe.rails_version - }) - ) - File.expects(:write).with( - File.join(path, "ingredients.json"), - JSON.pretty_generate(recipe.recipe_ingredients.order(:position).map(&:to_git_format)) - ) - - assert_raises(GitRepo::GitSyncError) do - @repo.write_model(recipe) - end - end - - test "uses correct repo name suffix in development environment" do - github_client = Minitest::Mock.new - GitRepo.any_instance.unstub(:remote_repo_exists?) - GitRepo.any_instance.stubs(:create_github_repo).returns(true) - GitRepo.any_instance.stubs(:create_initial_structure).returns(true) - GitRepo.any_instance.stubs(:setup_remote).returns(true) - - rails_env = mock - rails_env.stubs(:development?).returns(true) - rails_env.stubs(:test?).returns(false) - rails_env.stubs(:production?).returns(false) - Rails.stubs(:env).returns(rails_env) - - Octokit::Client.stub :new, github_client do - github_client.expect :repository?, false, [ "#{@user.github_username}/rails-new-io-data-dev" ] - repo = GitRepo.new(user: @user, repo_name: @repo_name) - assert_equal "rails-new-io-data-dev", repo.send(:repo_name) - github_client.verify - end - end - - test "uses correct repo name suffix in test environment" do - github_client = Minitest::Mock.new - GitRepo.any_instance.unstub(:remote_repo_exists?) - GitRepo.any_instance.stubs(:create_github_repo).returns(true) - GitRepo.any_instance.stubs(:create_initial_structure).returns(true) - GitRepo.any_instance.stubs(:setup_remote).returns(true) - - rails_env = mock - rails_env.stubs(:development?).returns(false) - rails_env.stubs(:test?).returns(true) - rails_env.stubs(:production?).returns(false) - rails_env.stubs(:to_s).returns("test") - Rails.stubs(:env).returns(rails_env) - - Octokit::Client.stub :new, github_client do - github_client.expect :repository?, false, [ "#{@user.github_username}/rails-new-io-data-test" ] - repo = GitRepo.new(user: @user, repo_name: @repo_name) - assert_equal "rails-new-io-data-test", repo.send(:repo_name) - github_client.verify - end - end - - test "uses base repo name in production environment" do - # First clear the existing instance to avoid multiple method calls - @repo = nil - - # Remove all existing stubs - GitRepo.any_instance.unstub(:remote_repo_exists?) - GitRepo.any_instance.unstub(:create_github_repo) - GitRepo.any_instance.unstub(:create_initial_structure) - GitRepo.any_instance.unstub(:setup_remote) - - Mocha::Configuration.override(stubbing_non_public_method: :allow) do - # Set up production environment in a block to ensure cleanup - rails_env = mock - rails_env.stubs(:development?).returns(false) - rails_env.stubs(:test?).returns(false) - rails_env.stubs(:production?).returns(true) - Rails.stubs(:env).returns(rails_env) - - # Mock GitHub client for this specific test - github_client = Minitest::Mock.new - github_client.expect :repository?, false, [ "#{@user.github_username}/rails-new-io-data" ] - - # Re-stub necessary methods after the repository check - GitRepo.any_instance.stubs(:create_github_repo).returns(true) - GitRepo.any_instance.stubs(:create_initial_structure).returns(true) - GitRepo.any_instance.stubs(:setup_remote).returns(true) - - Octokit::Client.stub :new, github_client do - repo = GitRepo.new(user: @user, repo_name: @repo_name) - assert_equal "rails-new-io-data", repo.send(:repo_name) - github_client.verify - end - end - end - - test "raises error for unknown Rails environment" do - GitRepo.any_instance.unstub(:remote_repo_exists?) - GitRepo.any_instance.stubs(:create_github_repo).returns(true) - GitRepo.any_instance.stubs(:create_initial_structure).returns(true) - GitRepo.any_instance.stubs(:setup_remote).returns(true) - - rails_env = mock - rails_env.stubs(:development?).returns(false) - rails_env.stubs(:test?).returns(false) - rails_env.stubs(:production?).returns(false) - rails_env.stubs(:to_s).returns("staging") - Rails.stubs(:env).returns(rails_env) - - assert_raises(ArgumentError, "Unknown Rails environment: staging") do - GitRepo.new(user: @user, repo_name: @repo_name) - end + test "creates new local repository when no repository exists" do + git_operations = [] + + # Clear any existing stubs from setup + File.unstub(:exist?) + + # Set up file existence checks + File.stubs(:exist?).returns(false) # Default to false for all paths + File.stubs(:exist?).with(@repo_path).returns(false) + File.stubs(:exist?).with(File.join(@repo_path, ".git")).returns(false) + + # Mock git branches and status (for later use) + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git.stubs(:branches).returns(branches_collection) + + status_mock = mock("status") + status_mock.stubs(:changed).returns({ "file1" => "modified" }) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git.stubs(:status).returns(status_mock) + + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git.stubs(:branch).returns(current_branch_mock) + + # Set up GitHub API expectations + # We expect two checks for remote_repo_exists? - both should return false initially + @mock_client.stubs(:repository?) + .with("#{@user.github_username}/#{@repo_name}") + .returns(false) + .then.returns(false) + .then.returns(true) # After creation + + @mock_client.expects(:create_repository) + .with(@repo_name, private: false, description: "Repository created via railsnew.io") + .returns(true) + + # Track git operations + @git.expects(:config).with("init.templateDir", "").tap { |_| git_operations << "config_template" } + @git.expects(:config).with("init.defaultBranch", "main").tap { |_| git_operations << "config_branch" } + @git.expects(:config).with("user.name", "Jane Smith").tap { |_| git_operations << "config_name" } + @git.expects(:config).with("user.email", "jane@example.com").tap { |_| git_operations << "config_email" } + @git.expects(:add).with(all: true).tap { |_| git_operations << "add" } + @git.expects(:commit).with("Test commit").tap { |_| git_operations << "commit" } + @git.expects(:add_remote).with( + "origin", + "https://fake-token@github.com/#{@user.github_username}/#{@repo_name}.git" + ).tap { |_| git_operations << "add_remote" } + @git.expects(:push).with("origin", "main").tap { |_| git_operations << "push" } + + + + @repo.commit_changes(message: "Test commit", author: @user) + + # Assert operations happened in correct order + expected_operations = [ + "config_template", + "config_branch", + "config_name", + "config_email", + "add", + "commit", + "add_remote", + "push" + ] + assert_equal expected_operations, git_operations, "Git operations were not performed in the expected order" end test "handles GitHub API errors when checking repository existence" do - # Clear the stub for remote_repo_exists? since we want to test it - GitRepo.any_instance.unstub(:remote_repo_exists?) - - # Create a test logger - test_logger = Class.new do - attr_reader :messages - def initialize - @messages = [] - end - - def error(message) - @messages << message - end - end.new - - Rails.stubs(:logger).returns(test_logger) - - # Create a client that raises an error error = Octokit::Error.new( method: :get, - url: "https://api.github.com/repos/#{@user.github_username}/rails-new-io-data-test", + url: "https://api.github.com/repos/#{@user.github_username}/#{@repo_name}", status: 401, response_headers: {}, body: { message: "Bad credentials" } ) - mock_client = mock - mock_client.expects(:repository?).raises(error) + test_logger = mock("logger") + test_logger.expects(:error).with("Failed to check GitHub repository: #{error.message}") + Rails.stubs(:logger).returns(test_logger) - # Test error handling - @repo.instance_variable_set(:@github_client, mock_client) - result = @repo.send(:remote_repo_exists?) + @mock_client.expects(:repository?).raises(error) - # Verify behavior - assert_equal false, result - assert_equal 1, test_logger.messages.size - assert_equal( - "Failed to check GitHub repository: GET https://api.github.com/repos/jane_smith/rails-new-io-data-test: 401 - Bad credentials", - test_logger.messages.first - ) + # Should return false and continue with local repo creation + @repo.send(:remote_repo_exists?) end -end - -class GitRepoCreateTest < ActiveSupport::TestCase - test "creates github repository with correct parameters" do - # Create a test user - test_user = User.new(github_username: "test_user", github_token: "fake-token") - - # Create a repo instance WITHOUT initialization - repo = Class.new(GitRepo) do - def initialize(user:) - @user = user - end - end.new(user: test_user) - - # Set up the test expectations - mock_client = mock("github_client") - mock_client.expects(:create_repository).with( - "rails-new-io-data-test", - private: false, - description: "Data repository for rails-new.io" - ).returns(true) - # Inject our dependencies - repo.stubs(:repo_name).returns("rails-new-io-data-test") - repo.instance_variable_set(:@github_client, mock_client) - - # Test just the create_github_repo method - repo.send(:create_github_repo) + test "handles missing user name and email" do + @user.stubs(:name).returns(nil) + @user.stubs(:email).returns(nil) + + File.stubs(:exist?).with(@repo_path).returns(true) + File.stubs(:exist?).with(File.join(@repo_path, ".git")).returns(true) + + # Mock git branches and status + remote_branch = mock("remote_branch") + remote_branch.stubs(:name).returns("origin/main") + branches_collection = mock("branches_collection") + branches_collection.stubs(:remote).returns([ remote_branch ]) + @git.stubs(:branches).returns(branches_collection) + + # Mock git status + status_mock = mock("status") + status_mock.stubs(:changed).returns({ "file1" => "modified" }) + status_mock.stubs(:added).returns({}) + status_mock.stubs(:deleted).returns({}) + @git.stubs(:status).returns(status_mock) + + # Mock current branch + current_branch_mock = mock("current_branch") + current_branch_mock.stubs(:name).returns("main") + @git.stubs(:branch).returns(current_branch_mock) + + # Track operations + operations = [] + @git.expects(:fetch).tap { operations << :fetch } + @git.expects(:reset_hard).with("origin/main").tap { operations << :reset } + @git.expects(:config).with("user.name", @user.github_username).tap { operations << :config_name } + @git.expects(:config).with("user.email", "#{@user.github_username}@users.noreply.github.com").tap { operations << :config_email } + @git.expects(:add).with(all: true).tap { operations << :add } + @git.expects(:commit).with("Test commit").tap { operations << :commit } + @git.expects(:push).with("origin", "main").tap { operations << :push } + + @mock_client.expects(:repository?).returns(true) + + @repo.commit_changes(message: "Test commit", author: @user) + + # Assert operations happened in correct order + expected_operations = [ :fetch, :reset, :config_name, :config_email, :add, :commit, :push ] + assert_equal expected_operations, operations, "Git operations were not performed in the expected order" end -end -class GitRepoStructureTest < ActiveSupport::TestCase - setup do - @user = users(:jane) - @user.define_singleton_method(:github_token) { "fake-token" } - @repo_name = "rails-new-io-data-test" - @repo_path = Rails.root.join("tmp", "git_repos", @user.id.to_s, @repo_name) - end + test "ensures committable state by creating README.md" do + File.stubs(:exist?).with(@repo_path).returns(true) + File.stubs(:exist?).with(File.join(@repo_path, ".git")).returns(true) - test "creates initial repository structure" do - # Create a minimal repo instance with readme_content method - repo = Class.new(GitRepo) do - def initialize(user:, repo_path:) - @user = user - @repo_path = repo_path - end - - private - - def readme_content - "# Data Repository\nThis repository contains data for rails-new.io" - end - end.new(user: @user, repo_path: @repo_path) - - # Mock Git operations - git = mock("git") - git.expects(:add).with(all: true) - git.expects(:commit).with("Initial commit") - repo.stubs(:git).returns(git) - - # Mock file operations - FileUtils.expects(:mkdir_p).with(File.join(@repo_path, "generated_apps")) - FileUtils.expects(:mkdir_p).with(File.join(@repo_path, "ingredients")) - FileUtils.expects(:mkdir_p).with(File.join(@repo_path, "recipes")) File.expects(:write).with( File.join(@repo_path, "README.md"), - "# Data Repository\nThis repository contains data for rails-new.io" + "# Repository\nCreated via railsnew.io" ) - # Test just the create_initial_structure method - repo.send(:create_initial_structure) + @repo.send(:ensure_committable_state) end -end -class GitRepoRemoteTest < ActiveSupport::TestCase - setup do - @user = users(:jane) - @user.define_singleton_method(:github_token) { "fake-token" } - @repo_name = "rails-new-io-data-test" - @repo_path = Rails.root.join("tmp", "git_repos", @user.id.to_s, @repo_name) + test "creates GitHub repository with correct parameters" do + @mock_client.expects(:create_repository).with( + @repo_name, + private: false, + description: "Repository created via railsnew.io" + ) + + @repo.send(:create_github_repo) end - test "sets up git remote with correct URL" do - # Create a minimal repo instance - repo = Class.new(GitRepo) do - def initialize(user:, repo_path:) - @user = user - @repo_path = repo_path - end - - def repo_name - "rails-new-io-data-test" - end - end.new(user: @user, repo_path: @repo_path) - - # Mock Git operations - git = mock("git") - git.expects(:add_remote).with( - "origin", - "https://fake-token@github.com/#{@user.github_username}/rails-new-io-data-test.git" - ) - repo.stubs(:git).returns(git) + test "initializes new git repo when .git directory doesn't exist" do + # Stub File.exist? to return false for .git directory + File.stubs(:exist?).returns(true) # default stub + File.stubs(:exist?).with(File.join(@repo_path, ".git")).returns(false) + + # Set up expectations for create_local_repo + FileUtils.expects(:mkdir_p).with(File.dirname(@repo_path)) + FileUtils.expects(:rm_rf).with(@repo_path) + FileUtils.expects(:mkdir_p).with(@repo_path) + + # Expect Git.init to be called and return our mock git object + Git.expects(:init).with(@repo_path).returns(@git) + + # Expect git config calls + @git.expects(:config).with("init.templateDir", "") + @git.expects(:config).with("init.defaultBranch", "main") + + # Call the git method + result = @repo.send(:git) - # Test the setup_remote method - repo.send(:setup_remote) + # Verify the result + assert_equal @git, result end end diff --git a/test/services/github_code_push_service_test.rb b/test/services/github_code_push_service_test.rb index 4a5be34..28608ba 100644 --- a/test/services/github_code_push_service_test.rb +++ b/test/services/github_code_push_service_test.rb @@ -3,6 +3,7 @@ class GithubCodePushServiceTest < ActiveSupport::TestCase def setup @user = users(:john) + @user.stubs(:github_token).returns("fake-token") @recipe = recipes(:blog_recipe) @temp_dir = Dir.mktmpdir @@ -159,7 +160,7 @@ def teardown test "push_code raises GitError when git operations fail" do @generated_app = generated_apps(:saas_starter) @user = @generated_app.user - @user.define_singleton_method(:github_token) { "fake-token" } + @user.stubs(:github_token).returns("fake-token") # Create directory structure but don't init git app_dir = File.join(@temp_dir, @generated_app.name) diff --git a/test/services/github_repository_name_validator_test.rb b/test/services/github_repository_name_validator_test.rb index 5e0494a..c4f63fc 100644 --- a/test/services/github_repository_name_validator_test.rb +++ b/test/services/github_repository_name_validator_test.rb @@ -5,60 +5,88 @@ def setup @owner = "test-owner" end - def test_valid_repository_name + def test_available_repository_name client = Minitest::Mock.new - client.expect(:repository, nil) { raise Octokit::NotFound } + client.expect(:repository?, false, [ "#{@owner}/valid-repo-name" ]) Octokit::Client.stub(:new, client) do validator = GithubRepositoryNameValidator.new("valid-repo-name", @owner) - assert validator.valid? + assert_not validator.repo_exists? end end def test_invalid_ending_with_hyphen validator = GithubRepositoryNameValidator.new("invalid-", @owner) - assert_not validator.valid? + assert_not validator.repo_exists? end def test_invalid_starting_with_hyphen validator = GithubRepositoryNameValidator.new("-invalid", @owner) - assert_not validator.valid? + assert_not validator.repo_exists? end def test_invalid_double_hyphen validator = GithubRepositoryNameValidator.new("invalid--name", @owner) - assert_not validator.valid? + assert_not validator.repo_exists? end def test_invalid_empty_string validator = GithubRepositoryNameValidator.new("", @owner) - assert_not validator.valid? + assert_not validator.repo_exists? end def test_invalid_special_characters validator = GithubRepositoryNameValidator.new("inv@lid", @owner) - assert_not validator.valid? + assert_not validator.repo_exists? end def test_invalid_when_repository_exists client = Minitest::Mock.new - client.expect(:repository, true, [ "#{@owner}/existing-repo" ]) + client.expect(:repository?, true, [ "#{@owner}/existing-repo" ]) Octokit::Client.stub(:new, client) do validator = GithubRepositoryNameValidator.new("existing-repo", @owner) - assert_not validator.valid? + assert validator.repo_exists? end assert_mock client end def test_invalid_with_nil_name + validator = GithubRepositoryNameValidator.new(nil, @owner) + assert_not validator.repo_exists? + end + + def test_handles_github_api_errors + error = Octokit::Error.new( + method: :get, + url: "https://api.github.com/repos/#{@owner}/test-repo", + status: 401, + response_headers: {}, + body: { message: "Bad credentials" } + ) + + # Set up logger mock + mock_logger = mock("logger") + mock_logger.expects(:error).with( + "GitHub API error: GET https://api.github.com/repos/#{@owner}/test-repo: 401 - Bad credentials" + ) + Rails.stubs(:logger).returns(mock_logger) + + # Set up client mock to raise error client = Minitest::Mock.new - client.expect(:repository, nil) { raise Octokit::NotFound } + client.expect(:repository?, nil) { raise error } Octokit::Client.stub(:new, client) do - validator = GithubRepositoryNameValidator.new(nil, @owner) - assert_not validator.valid? + validator = GithubRepositoryNameValidator.new("test-repo", @owner) + + error = assert_raises(Octokit::Error) do + validator.repo_exists? + end + + assert_equal "GET https://api.github.com/repos/#{@owner}/test-repo: 401 - Bad credentials", error.message end + + assert_mock client end end diff --git a/test/services/github_repository_service_test.rb b/test/services/github_repository_service_test.rb index 6a96e75..72f1cd7 100644 --- a/test/services/github_repository_service_test.rb +++ b/test/services/github_repository_service_test.rb @@ -5,8 +5,7 @@ class GithubRepositoryServiceTest < ActiveSupport::TestCase def setup @user = users(:john) - # Define github_token method to bypass encryption - @user.define_singleton_method(:github_token) { "fake-token" } + @user.stubs(:github_token).returns("fake-token") @generated_app = generated_apps(:pending_app) @app_status = @generated_app.app_status @@ -33,21 +32,17 @@ def setup description: "Repository created via railsnew.io" } ] - @service.stub :client, mock_client do - assert_difference -> { @user.repositories.count }, 1 do - assert_difference -> { AppGeneration::LogEntry.count }, 3 do - result = @service.create_repository(@repository_name) - assert_equal response.html_url, result.html_url - end - end + Octokit::Client.stub :new, mock_client do + response = @service.create_repository(@repository_name) + assert_equal "https://github.com/#{@user.github_username}/#{@repository_name}", response.html_url - # Verify log entries - log_entries = @generated_app.log_entries.recent_first - assert_equal "GitHub repo #{@repository_name} created successfully", log_entries.first.message - assert_equal "Creating repository: #{@repository_name}", log_entries.second.message + # Verify GeneratedApp was updated + @generated_app.reload + assert_equal @repository_name, @generated_app.github_repo_name + assert_equal response.html_url, @generated_app.github_repo_url end - mock_client.verify + assert_mock mock_client end test "raises error when repository already exists" do @@ -173,25 +168,4 @@ def mock_client.repository?(*) end end end - - test "client initializes Octokit::Client with correct parameters" do - User.any_instance.stubs(:github_token).returns("fake-token") - - mock_client = mock("client") - Octokit::Client.expects(:new).with( - access_token: "fake-token", - auto_paginate: true - ).returns(mock_client) - - @service.send(:client) - end - - test "client memoizes the instance" do - User.any_instance.stubs(:github_token).returns("fake-token") - - first_client = @service.send(:client) - second_client = @service.send(:client) - - assert_same first_client, second_client - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0b913ce..74a7e8d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,7 +16,7 @@ add_group "Jobs", "app/jobs" add_group "Mailers", "app/mailers" - track_files "{app/models,app/controllers,app/helpers}/**/*.rb" + track_files "{app/models,app/controllers,app/helpers,app/jobs}/**/*.rb" end end