From 62ed5e30243181b5fa3df5ffeb6824727bf9b6f0 Mon Sep 17 00:00:00 2001 From: Mohamed Elhayany <40873107+Melhaya@users.noreply.github.com> Date: Thu, 1 Aug 2024 14:57:40 +0200 Subject: [PATCH] Allow users to configure a personal OpenAI API key (#1496) Co-authored-by: Karol Co-authored-by: Sebastian Serth --- app/controllers/tasks_controller.rb | 8 +- .../users/registrations_controller.rb | 2 +- app/errors/gpt/error.rb | 17 +++ app/errors/gpt/invalid_task_description.rb | 5 - app/errors/gpt/missing_language_error.rb | 5 - app/models/user.rb | 9 ++ app/policies/task_policy.rb | 2 +- .../generate_tests.rb} | 53 ++++---- app/services/gpt_service/gpt_service_base.rb | 23 ++++ app/services/gpt_service/validate_api_key.rb | 22 ++++ app/views/tasks/show.html.slim | 13 +- app/views/users/registrations/edit.html.slim | 10 ++ app/views/users/show.html.slim | 9 ++ config/initializers/open_ai.rb | 7 -- config/locales/de/controllers/tasks.yml | 2 - config/locales/de/errors.yml | 9 ++ config/locales/de/models.yml | 1 + config/locales/de/views/tasks.yml | 1 + config/locales/de/views/users.yml | 4 + config/locales/en/controllers/tasks.yml | 2 - config/locales/en/errors.yml | 9 ++ config/locales/en/models.yml | 1 + config/locales/en/views/tasks.yml | 1 + config/locales/en/views/users.yml | 4 + config/settings/development.yml | 1 - config/settings/production.yml | 1 - config/settings/test.yml | 1 - ...40703221801_add_openai_api_key_to_users.rb | 7 ++ db/schema.rb | 3 +- spec/controllers/tasks_controller_spec.rb | 27 ++-- spec/errors/gpt/error_spec.rb | 11 ++ spec/models/user_spec.rb | 49 ++++++++ spec/policies/task_policy_spec.rb | 54 ++------ .../gpt_service/generate_tests_spec.rb | 118 ++++++++++++++++++ .../gpt_service/validate_api_key_spec.rb | 83 ++++++++++++ .../task_service/gpt_generate_tests_spec.rb | 67 ---------- 36 files changed, 457 insertions(+), 184 deletions(-) create mode 100644 app/errors/gpt/error.rb delete mode 100644 app/errors/gpt/invalid_task_description.rb delete mode 100644 app/errors/gpt/missing_language_error.rb rename app/services/{task_service/gpt_generate_tests.rb => gpt_service/generate_tests.rb} (56%) create mode 100644 app/services/gpt_service/gpt_service_base.rb create mode 100644 app/services/gpt_service/validate_api_key.rb delete mode 100644 config/initializers/open_ai.rb create mode 100644 config/locales/de/errors.yml create mode 100644 config/locales/en/errors.yml create mode 100644 db/migrate/20240703221801_add_openai_api_key_to_users.rb create mode 100644 spec/errors/gpt/error_spec.rb create mode 100644 spec/services/gpt_service/generate_tests_spec.rb create mode 100644 spec/services/gpt_service/validate_api_key_spec.rb delete mode 100644 spec/services/task_service/gpt_generate_tests_spec.rb diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 0b9120a4d..2a4c7315a 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -209,12 +209,10 @@ def export_external_confirm # rubocop:enable Metrics/AbcSize def generate_test - TaskService::GptGenerateTests.call(task: @task) + GptService::GenerateTests.call(task: @task, openai_api_key: current_user.openai_api_key) flash[:notice] = I18n.t('tasks.task_service.gpt_generate_tests.successful_generation') - rescue Gpt::MissingLanguageError - flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.no_language') - rescue Gpt::InvalidTaskDescription - flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.invalid_description') + rescue Gpt::Error => e + flash[:alert] = e.localized_message ensure redirect_to @task end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 23df9f4ac..e44053eaf 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -58,7 +58,7 @@ def configure_sign_up_params # If you have extra params to permit, append them to the sanitizer. def configure_account_update_params - devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar]) + devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar openai_api_key]) end def after_update_path_for(resource) diff --git a/app/errors/gpt/error.rb b/app/errors/gpt/error.rb new file mode 100644 index 000000000..0b2fcd16a --- /dev/null +++ b/app/errors/gpt/error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gpt + class Error < ApplicationError + class InternalServerError < Error; end + + class InvalidApiKey < Error; end + + class InvalidTaskDescription < Error; end + + class MissingLanguage < Error; end + + def localized_message + I18n.t("errors.gpt.#{self.class.name&.demodulize&.underscore}") + end + end +end diff --git a/app/errors/gpt/invalid_task_description.rb b/app/errors/gpt/invalid_task_description.rb deleted file mode 100644 index 458851869..000000000 --- a/app/errors/gpt/invalid_task_description.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module Gpt - class InvalidTaskDescription < StandardError; end -end diff --git a/app/errors/gpt/missing_language_error.rb b/app/errors/gpt/missing_language_error.rb deleted file mode 100644 index 57d9f6a7b..000000000 --- a/app/errors/gpt/missing_language_error.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module Gpt - class MissingLanguageError < StandardError; end -end diff --git a/app/models/user.rb b/app/models/user.rb index 8d5886ab1..d7867f0f6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord validates :email, presence: true, uniqueness: {case_sensitive: false} validates :first_name, :last_name, :status_group, presence: true validates :password_set, inclusion: [true, false] + validate :validate_openai_api_key, if: -> { openai_api_key.present? } has_many :tasks, dependent: :nullify @@ -148,6 +149,14 @@ def to_s private + def validate_openai_api_key + return unless openai_api_key_changed? + + GptService::ValidateApiKey.call(openai_api_key:) + rescue Gpt::Error::InvalidApiKey + errors.add(:base, :invalid_api_key) + end + def avatar_format avatar_blob = avatar.blob if avatar_blob.content_type.start_with? 'image/' diff --git a/app/policies/task_policy.rb b/app/policies/task_policy.rb index e481d74b8..350511ae8 100644 --- a/app/policies/task_policy.rb +++ b/app/policies/task_policy.rb @@ -54,7 +54,7 @@ def manage? end def generate_test? - Settings.open_ai.access_token.present? and update? + user&.openai_api_key.present? and update? end private diff --git a/app/services/task_service/gpt_generate_tests.rb b/app/services/gpt_service/generate_tests.rb similarity index 56% rename from app/services/task_service/gpt_generate_tests.rb rename to app/services/gpt_service/generate_tests.rb index 72b60482e..fdd9e6316 100644 --- a/app/services/task_service/gpt_generate_tests.rb +++ b/app/services/gpt_service/generate_tests.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true -module TaskService - class GptGenerateTests < ServiceBase - def initialize(task:) +module GptService + class GenerateTests < GptServiceBase + def initialize(task:, openai_api_key:) super() - raise Gpt::MissingLanguageError if task.programming_language&.language.blank? + raise Gpt::Error::MissingLanguage if task.programming_language&.language.blank? @task = task + @client = new_client! openai_api_key end def execute @@ -20,35 +21,33 @@ def execute private - def client - @client ||= OpenAI::Client.new - end - def gpt_response - # train client with some prompts - messages = training_prompts.map do |prompt| - {role: 'system', content: prompt} - end + wrap_api_error! do + # train client with some prompts + messages = training_prompts.map do |prompt| + {role: 'system', content: prompt} + end - # send user message - messages << {role: 'user', content: @task.description} + # send user message + messages << {role: 'user', content: @task.description} - # create gpt client - response = client.chat( - parameters: { - model: Settings.open_ai.model, - messages:, - temperature: 0.7, # Lower values insure reproducibility - } - ) + # create gpt client + response = @client.chat( + parameters: { + model: Settings.open_ai.model, + messages:, + temperature: 0.7, # Lower values insure reproducibility + } + ) - # parse out the response - raw_response = response.dig('choices', 0, 'message', 'content') + # parse out the response + raw_response = response.dig('choices', 0, 'message', 'content') - # check for ``` in the response and extract the text between the first set - raise Gpt::InvalidTaskDescription unless raw_response.include?('```') + # check for ``` in the response and extract the text between the first set + raise Gpt::Error::InvalidTaskDescription unless raw_response.include?('```') - raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip + raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip + end end def training_prompts diff --git a/app/services/gpt_service/gpt_service_base.rb b/app/services/gpt_service/gpt_service_base.rb new file mode 100644 index 000000000..7d8a46ba5 --- /dev/null +++ b/app/services/gpt_service/gpt_service_base.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module GptService + class GptServiceBase < ServiceBase + def new_client!(access_token) + raise Gpt::Error::InvalidApiKey if access_token.blank? + + OpenAI::Client.new(access_token:) + end + + private + + def wrap_api_error! + yield + rescue Faraday::UnauthorizedError, OpenAI::Error => e + raise Gpt::Error::InvalidApiKey.new("Could not authenticate with OpenAI: #{e.message}") + rescue Faraday::Error => e + raise Gpt::Error::InternalServerError.new("Could not communicate with OpenAI: #{e.inspect}") + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError => e + raise Gpt::Error.new(e) + end + end +end diff --git a/app/services/gpt_service/validate_api_key.rb b/app/services/gpt_service/validate_api_key.rb new file mode 100644 index 000000000..c54697e69 --- /dev/null +++ b/app/services/gpt_service/validate_api_key.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module GptService + class ValidateApiKey < GptServiceBase + def initialize(openai_api_key:) + super() + + @client = new_client! openai_api_key + end + + def execute + validate! + end + + def validate! + wrap_api_error! do + response = @client.models.list + raise Gpt::Error::InvalidApiKey unless response['data'] + end + end + end +end diff --git a/app/views/tasks/show.html.slim b/app/views/tasks/show.html.slim index 595118ad9..a25378978 100644 --- a/app/views/tasks/show.html.slim +++ b/app/views/tasks/show.html.slim @@ -574,9 +574,18 @@ =< t('common.button.duplicate') - if policy(@task).generate_test? - = link_to generate_test_task_path(@task), method: :post, class: 'btn btn-important' do + = link_to generate_test_task_path(@task), + method: :post, + class: 'btn btn-important' do i.fa-solid.fa-wand-magic-sparkles - =< t('.button.generate_test') + = t('.button.generate_test') + - elsif policy(@task).update? + div data-bs-toggle='tooltip' title=t('.button.api_key_required') data-bs-delay=150 + = link_to generate_test_task_path(@task), + method: :post, + class: 'btn btn-important disabled' do + i.fa-solid.fa-wand-magic-sparkles + = t('.button.generate_test') - if current_user.present? = link_to t('common.button.back'), tasks_path, class: 'btn btn-important' diff --git a/app/views/users/registrations/edit.html.slim b/app/views/users/registrations/edit.html.slim index 70320d7e3..52a87473b 100644 --- a/app/views/users/registrations/edit.html.slim +++ b/app/views/users/registrations/edit.html.slim @@ -51,6 +51,16 @@ autocomplete: 'new-password', class: 'form-control' + .form-group.field-element + = f.label :openai_api_key, t('users.show.openai_api_key'), class: 'form-label' + = f.text_field :openai_api_key, + required: false, + placeholder: t('users.show.openai_api_key'), + autocomplete: 'off', + class: 'form-control' + small.form-text.text-body-secondary + = t('.openai_api_key_usage_html', openai_api_link: 'https://platform.openai.com/api-keys') + = render 'avatar_form', f: - if resource.password_set? diff --git a/app/views/users/show.html.slim b/app/views/users/show.html.slim index ad1e39c19..ea1b374fd 100644 --- a/app/views/users/show.html.slim +++ b/app/views/users/show.html.slim @@ -46,6 +46,15 @@ | : .col.row-value = @user.email + .row + .col-auto.row-label + = t('.openai_api_key') + | : + .col.row-value + - if @user.openai_api_key.present? + = t('.openai_api_key_provided') + - else + = t('.openai_api_key_not_provided') .row.vertical .col.row-label = t('.account_links.created') diff --git a/config/initializers/open_ai.rb b/config/initializers/open_ai.rb deleted file mode 100644 index 1cfe95249..000000000 --- a/config/initializers/open_ai.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -OpenAI.configure do |config| - next unless Settings.open_ai - - config.access_token = Settings.open_ai.access_token -end diff --git a/config/locales/de/controllers/tasks.yml b/config/locales/de/controllers/tasks.yml index f41b16df0..fe3b551c0 100644 --- a/config/locales/de/controllers/tasks.yml +++ b/config/locales/de/controllers/tasks.yml @@ -29,6 +29,4 @@ de: task_found: 'In der externen App wurde eine entsprechende Aufgabe gefunden. Sie können: ' task_found_no_right: 'In der externen App wurde eine entsprechende Aufgabe gefunden, aber Sie haben keine Rechte, sie zu bearbeiten. Sie können: ' gpt_generate_tests: - invalid_description: Die angegebene Aufgabenbeschreibung stellt keine gültige Programmieraufgabe dar und kann daher nicht zum Generieren eines Unit-Tests genutzt werden. Bitte stellen Sie sicher, dass die Aufgabenbeschreibung eine klar formulierte Problemstellung enthält, die durch ein Programm gelöst werden kann. - no_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren. successful_generation: Unit-Test erfolgreich generiert. Bitte überprüfen Sie den generierten Test und vergeben Sie einen passenden Dateinamen. diff --git a/config/locales/de/errors.yml b/config/locales/de/errors.yml new file mode 100644 index 000000000..101792849 --- /dev/null +++ b/config/locales/de/errors.yml @@ -0,0 +1,9 @@ +--- +de: + errors: + gpt: + error: Beim Generieren des Unit-Tests ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut. + internal_server_error: Der OpenAI-Server ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut, wenn das Problem behoben wurde. + invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig oder abgelaufen. Bitte aktualisieren Sie den API-Schlüssel in Ihrem Profil um die KI-Funktionen zu nutzen und versuchen Sie es anschließend erneut. + invalid_task_description: Die angegebene Aufgabenbeschreibung stellt keine gültige Programmieraufgabe dar und kann daher nicht zum Generieren eines Unit-Tests genutzt werden. Bitte stellen Sie sicher, dass die Aufgabenbeschreibung eine klar formulierte Problemstellung enthält, die durch ein Programm gelöst werden kann. + missing_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren. diff --git a/config/locales/de/models.yml b/config/locales/de/models.yml index 1067db551..fd73f77c7 100644 --- a/config/locales/de/models.yml +++ b/config/locales/de/models.yml @@ -118,6 +118,7 @@ de: avatar: not_an_image: muss ein Bild sein size_over_10_mb: Größe muss kleiner als 10MB sein + invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig. Bitte fügen Sie den entsprechenden API-Schlüssel in Ihrem Profil hinzu, um die KI-Funktionen zu nutzen. models: account_link: one: Account-Link diff --git a/config/locales/de/views/tasks.yml b/config/locales/de/views/tasks.yml index dd81581f9..7f7a87b88 100644 --- a/config/locales/de/views/tasks.yml +++ b/config/locales/de/views/tasks.yml @@ -83,6 +83,7 @@ de: add_to_collection_hint: Speichern Sie Aufgaben für später, indem Sie sie zu einer Sammlung hinzufügen. button: add_to_collection: Zu Sammlung hinzufügen + api_key_required: OpenAI API-Schlüssel ist erforderlich, um einen Test zu erstellen create_collection: Neue Sammlung anlegen download_as_zip: Diese Aufgabe als ZIP-Datei herunterladen. export: Exportieren diff --git a/config/locales/de/views/users.yml b/config/locales/de/views/users.yml index 4f5ba2b29..71b5ee1c9 100644 --- a/config/locales/de/views/users.yml +++ b/config/locales/de/views/users.yml @@ -28,6 +28,7 @@ de: add_identity: Account mit %{kind} verknüpfen cannot_remove_last_identity: Account-Verknüpfung zu %{kind} kann nicht entfernt werden, da weder ein Passwort gesetzt noch eine andere Identität verknüpft ist manage_omniauth: Verknüpfte Accounts verwalten + openai_api_key_usage_html: Geben Sie hier Ihren OpenAI-API-Schlüssel ein, um verschiedene KI-Funktionen innerhalb der Plattform zu nutzen. Sie können einen API-Schlüssel auf der OpenAI-Website erstellen. remove_identity: Account-Verknüpfung zu %{kind} entfernen shared: notification_modal: @@ -51,6 +52,9 @@ de: delete_modal: title: Warnung full_name: Vollständiger Name + openai_api_key: OpenAI API-Schlüssel + openai_api_key_not_provided: Nicht eingegeben + openai_api_key_provided: Eingegeben private_information: Private Informationen public_information: Öffentliche Informationen send_message: Nachricht senden diff --git a/config/locales/en/controllers/tasks.yml b/config/locales/en/controllers/tasks.yml index 208cc48de..b3dc07df5 100644 --- a/config/locales/en/controllers/tasks.yml +++ b/config/locales/en/controllers/tasks.yml @@ -29,6 +29,4 @@ en: task_found: 'A corresponding task has been found on the external app. You can: ' task_found_no_right: 'A corresponding task has been found on external app, but you don''t have the rights to edit it. You can: ' gpt_generate_tests: - invalid_description: The task description provided does not represent a valid programming task and therefore cannot be used to generate a unit test. Please make sure that the task description contains a clearly formulated problem that can be solved by a program. - no_language: Programming language is not specified for this task. Please specify the language before proceeding. successful_generation: Unit test generated successfully. Please check the generated test and assign an appropriate filename. diff --git a/config/locales/en/errors.yml b/config/locales/en/errors.yml new file mode 100644 index 000000000..c73caf65a --- /dev/null +++ b/config/locales/en/errors.yml @@ -0,0 +1,9 @@ +--- +en: + errors: + gpt: + error: An error occurred while generating the unit test. Please try again later. + internal_server_error: The OpenAI server is currently experiencing an outage. Please try again later when the issue is fixed. + invalid_api_key: The API key in your profile is invalid or has expired. Please update the API key in your profile to use the AI features and try again. + invalid_task_description: The task description provided does not represent a valid programming task and therefore cannot be used to generate a unit test. Please make sure that the task description contains a clearly formulated problem that can be solved by a program. + missing_language: Programming language is not specified for this task. Please specify the language before proceeding. diff --git a/config/locales/en/models.yml b/config/locales/en/models.yml index 8e88f0e97..be7c8a163 100644 --- a/config/locales/en/models.yml +++ b/config/locales/en/models.yml @@ -118,6 +118,7 @@ en: avatar: not_an_image: needs to be an image size_over_10_mb: size needs to be less than 10MB + invalid_api_key: The API key in your profile is invalid. Please add the appropriate API key in your profile to use the AI features. models: account_link: one: Account Link diff --git a/config/locales/en/views/tasks.yml b/config/locales/en/views/tasks.yml index de76b8374..34ce672f4 100644 --- a/config/locales/en/views/tasks.yml +++ b/config/locales/en/views/tasks.yml @@ -83,6 +83,7 @@ en: add_to_collection_hint: Save Tasks for later by adding them to a collection. button: add_to_collection: Add to Collection + api_key_required: OpenAI API key is required to generate a test create_collection: Create new Collection download_as_zip: Download this Task as a ZIP file. export: Export diff --git a/config/locales/en/views/users.yml b/config/locales/en/views/users.yml index ef03b382a..c6a6b63e2 100644 --- a/config/locales/en/views/users.yml +++ b/config/locales/en/views/users.yml @@ -28,6 +28,7 @@ en: add_identity: Link %{kind} account cannot_remove_last_identity: Cannot remove account link to %{kind} because neither a password is set nor another identity is linked manage_omniauth: Manage linked accounts + openai_api_key_usage_html: Enter your OpenAI API key here to use various AI features within the platform. You can create an API key on the OpenAI website. remove_identity: Remove account link to %{kind} shared: notification_modal: @@ -51,6 +52,9 @@ en: delete_modal: title: Warning full_name: Full name + openai_api_key: OpenAI API Key + openai_api_key_not_provided: Not entered + openai_api_key_provided: Entered private_information: Private Information public_information: Public Information send_message: Send Message diff --git a/config/settings/development.yml b/config/settings/development.yml index dcf6a47fa..6d4badc15 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -14,5 +14,4 @@ omniauth: oai_pmh: admin_mail: admin@example.org open_ai: - access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys model: gpt-4o-mini diff --git a/config/settings/production.yml b/config/settings/production.yml index ede2f9c17..a3c0aeebf 100644 --- a/config/settings/production.yml +++ b/config/settings/production.yml @@ -15,5 +15,4 @@ omniauth: oai_pmh: admin_mail: admin@example.org open_ai: - access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys model: gpt-4o-mini diff --git a/config/settings/test.yml b/config/settings/test.yml index b4834dd4c..dfb4a50be 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -32,5 +32,4 @@ nbp: name: CodeHarbor slug: CoHaP2 open_ai: - access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys model: gpt-4o-mini diff --git a/db/migrate/20240703221801_add_openai_api_key_to_users.rb b/db/migrate/20240703221801_add_openai_api_key_to_users.rb new file mode 100644 index 000000000..dba7151fc --- /dev/null +++ b/db/migrate/20240703221801_add_openai_api_key_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOpenaiApiKeyToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :openai_api_key, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 63e7ecf87..542373e9c 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[7.1].define(version: 2024_06_09_104041) do +ActiveRecord::Schema[7.1].define(version: 2024_07_03_221801) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -430,6 +430,7 @@ t.string "preferred_locale" t.boolean "password_set", default: true, null: false t.integer "status_group", limit: 2, default: 0, null: false, comment: "Used as enum in Rails" + t.string "openai_api_key" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/spec/controllers/tasks_controller_spec.rb b/spec/controllers/tasks_controller_spec.rb index 997795e89..04d2cb051 100644 --- a/spec/controllers/tasks_controller_spec.rb +++ b/spec/controllers/tasks_controller_spec.rb @@ -1076,27 +1076,24 @@ end describe 'POST #generate_test' do - let(:task_user) { create(:user) } + let(:task_user) { create(:user, openai_api_key: 'valid_api_key') } let(:access_level) { :public } let(:task) { create(:task, user: task_user, access_level:) } + let(:mock_models) { instance_double(OpenAI::Models, list: {'data' => [{'id' => 'model-id'}]}) } before do + allow(OpenAI::Client).to receive(:new).and_return(instance_double(OpenAI::Client, models: mock_models)) sign_in task_user - Settings.open_ai.access_token = 'access_token' - end - - after do - Settings.open_ai.access_token = nil end context 'when GptGenerateTests is successful' do before do - allow(TaskService::GptGenerateTests).to receive(:call) + allow(GptService::GenerateTests).to receive(:call) post :generate_test, params: {id: task.id} end - it 'calls the GptGenerateTests service with the correct task' do - expect(TaskService::GptGenerateTests).to have_received(:call).with(task:) + it 'calls the GptGenerateTests service with the correct parameters' do + expect(GptService::GenerateTests).to have_received(:call).with(task:, openai_api_key: 'valid_api_key') end it 'redirects to the task show page' do @@ -1108,9 +1105,9 @@ end end - context 'when GptGenerateTests raises MissingLanguageError' do + context 'when GptGenerateTests raises Gpt::Error::MissingLanguage' do before do - allow(TaskService::GptGenerateTests).to receive(:call).and_raise(Gpt::MissingLanguageError) + allow(GptService::GenerateTests).to receive(:call).and_raise(Gpt::Error::MissingLanguage) post :generate_test, params: {id: task.id} end @@ -1119,13 +1116,13 @@ end it 'sets flash to the appropriate message' do - expect(flash[:alert]).to eq(I18n.t('tasks.task_service.gpt_generate_tests.no_language')) + expect(flash[:alert]).to eq(I18n.t('errors.gpt.missing_language')) end end - context 'when GptGenerateTests raises InvalidTaskDescription' do + context 'when GptGenerateTests raises Gpt::Error::InvalidTaskDescription' do before do - allow(TaskService::GptGenerateTests).to receive(:call).and_raise(Gpt::InvalidTaskDescription) + allow(GptService::GenerateTests).to receive(:call).and_raise(Gpt::Error::InvalidTaskDescription) post :generate_test, params: {id: task.id} end @@ -1134,7 +1131,7 @@ end it 'sets flash to the appropriate message' do - expect(flash[:alert]).to eq(I18n.t('tasks.task_service.gpt_generate_tests.invalid_description')) + expect(flash[:alert]).to eq(I18n.t('errors.gpt.invalid_task_description')) end end end diff --git a/spec/errors/gpt/error_spec.rb b/spec/errors/gpt/error_spec.rb new file mode 100644 index 000000000..4758defdf --- /dev/null +++ b/spec/errors/gpt/error_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Gpt::Error do + it 'has localized messages for all error classes' do + error_classes = described_class.descendants || [] + sample_errors = error_classes.map(&:new) + expect { sample_errors.map(&:localized_message) }.not_to raise_exception + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f864ef6e8..2ab663aa5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -50,6 +50,55 @@ expect(user.errors[:avatar]).to include(I18n.t('activerecord.errors.models.user.attributes.avatar.size_over_10_mb')) end end + + context 'when openai_api_key is present and valid' do + let(:openai_api_key) { 'valid_key' } + + before do + allow(GptService::ValidateApiKey).to receive(:call).with(openai_api_key:) + user.update(openai_api_key:) + end + + it 'is valid' do + expect(user).to be_valid + end + end + + context 'when openai_api_key is present and invalid' do + let(:openai_api_key) { 'invalid_key' } + + before do + allow(GptService::ValidateApiKey).to receive(:call).with(openai_api_key:).and_raise(Gpt::Error::InvalidApiKey) + user.update(openai_api_key:) + end + + it 'is not valid' do + expect(user).not_to be_valid + end + + it 'adds an error for invalid api key' do + user.valid? + expect(user.errors[:base]).to include(I18n.t('activerecord.errors.models.user.invalid_api_key')) + end + end + + context 'when openai_api_key remains the same' do + let(:openai_api_key) { 'same_key' } + + before do + allow(GptService::ValidateApiKey).to receive(:call).with(openai_api_key:) + user.update(openai_api_key:) + end + + it 'does not trigger API validation' do + expect(GptService::ValidateApiKey).not_to receive(:call) + expect { user.update(openai_api_key:) }.not_to change(user, :openai_api_key) + end + + it 'is valid' do + expect(user).to be_valid + end + end end describe '#destroy' do diff --git a/spec/policies/task_policy_spec.rb b/spec/policies/task_policy_spec.rb index 3fccd05f7..03d008876 100644 --- a/spec/policies/task_policy_spec.rb +++ b/spec/policies/task_policy_spec.rb @@ -9,6 +9,7 @@ let(:groups) { [] } let(:access_level) { :private } let(:task) { create(:task, user: task_user, access_level:, groups:) } + let(:openai_api_key) { nil } context 'without a user' do let(:user) { nil } @@ -27,28 +28,19 @@ end context 'with a user' do - let(:user) { create(:user) } + let(:user) { create(:user, openai_api_key:) } let(:generic_user_permissions) { %i[index new import_start import_confirm import_uuid_check import_external] } it { is_expected.to permit_only_actions(generic_user_permissions) } context 'when user is admin' do - let(:user) { create(:admin) } + let(:user) { create(:admin, openai_api_key:) } context 'without gpt access token' do - it { is_expected.to forbid_only_actions %i[generate_test] } - end - - context 'with gpt access token' do - before do - Settings.open_ai.access_token = 'access_token' - end + let(:openai_api_key) { nil } - after do - Settings.open_ai.access_token = nil - end - - it { is_expected.to permit_all_actions } + it { is_expected.to forbid_actions(%i[generate_test]) } + it { is_expected.to permit_actions(generic_user_permissions) } end end @@ -56,19 +48,10 @@ let(:task_user) { user } context 'without gpt access token' do - it { is_expected.to forbid_only_actions %i[generate_test] } - end - - context 'with gpt access token' do - before do - Settings.open_ai.access_token = 'access_token' - end - - after do - Settings.open_ai.access_token = nil - end + let(:openai_api_key) { nil } - it { is_expected.to permit_all_actions } + it { is_expected.to forbid_actions(%i[generate_test]) } + it { is_expected.to permit_actions(generic_user_permissions + %i[edit update show export_external_start export_external_check export_external_confirm download add_to_collection duplicate]) } end end @@ -81,7 +64,7 @@ context 'when task is "private" and in same group' do let(:access_level) { :private } - let(:user) { create(:user) } + let(:user) { create(:user, openai_api_key:) } let(:role) { :confirmed_member } let(:group_memberships) { [build(:group_membership, :with_admin), build(:group_membership, user:, role:)] } @@ -93,21 +76,10 @@ let(:role) { :admin } context 'without gpt access token' do - it { is_expected.to permit_only_actions(group_member_permissions) } - end - - context 'with gpt access token' do - let(:group_member_permissions) { generic_user_permissions + %i[edit update duplicate show export_external_start export_external_check export_external_confirm download add_to_collection duplicate generate_test] } - - before do - Settings.open_ai.access_token = 'access_token' - end - - after do - Settings.open_ai.access_token = nil - end + let(:openai_api_key) { nil } - it { is_expected.to permit_only_actions(group_member_permissions) } + it { is_expected.to forbid_actions(%i[generate_test]) } + it { is_expected.to permit_actions(group_member_permissions) } end end end diff --git a/spec/services/gpt_service/generate_tests_spec.rb b/spec/services/gpt_service/generate_tests_spec.rb new file mode 100644 index 000000000..d30aec325 --- /dev/null +++ b/spec/services/gpt_service/generate_tests_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GptService::GenerateTests do + let(:openai_api_key) { 'valid_api_key' } + let(:openai_client) { OpenAI::Client.new(access_token: openai_api_key) } + let(:openai_models) { instance_double(OpenAI::Models, list: {'data' => [{'id' => 'model-id'}]}) } + + let(:programming_language) { create(:programming_language, :python) } + let(:task) { create(:task, description: 'Create a Python script.', programming_language:) } + + before do + allow(OpenAI::Client).to receive(:new).and_return(openai_client) + allow(openai_client).to receive(:models).and_return(openai_models) + end + + describe '.new' do + subject(:gpt_generate_tests_service) { described_class.new(task:, openai_api_key:) } + + it 'assigns the task' do + expect(gpt_generate_tests_service.instance_variable_get(:@task)).to be task + end + + it 'assigns the client for OpenAI' do + expect(gpt_generate_tests_service.instance_variable_get(:@client)).to be openai_client + end + + it 'stores the OpenAI API key in the client' do + expect(gpt_generate_tests_service.instance_variable_get(:@client).access_token).to eq openai_api_key + end + + context 'when language is missing' do + let(:programming_language) { nil } + + it 'raises MissingLanguageError' do + expect { gpt_generate_tests_service }.to raise_error(Gpt::Error::MissingLanguage) + end + end + + context 'when API key is missing' do + let(:openai_api_key) { nil } + + it 'raises InvalidApiKeyError' do + expect { gpt_generate_tests_service }.to raise_error(Gpt::Error::InvalidApiKey) + end + end + end + + describe '#call' do + subject(:gpt_generate_tests) { described_class.call(task:, openai_api_key:) } + + let(:chat_response) { {'choices' => [{'message' => {'content' => "```Python\ndef test_script():\n assert true```"}}]} } + + before do + allow(openai_client).to receive(:chat).and_return(chat_response) + end + + context 'when the response includes valid code blocks' do + before do + gpt_generate_tests + end + + it 'creates a test file related to the task' do + test_file = task.reload.tests.last.files.first + expect(test_file).to have_attributes( + content: "def test_script():\n assert true", + name: 'test.py' + ) + end + + it 'creates a test instance related to the task' do + test = task.reload.tests.last + expect(test).to have_attributes(title: I18n.t('tests.model.generated_test')) + end + end + + context 'when the response does not contain backticks' do + let(:chat_response) { {'choices' => [{'message' => {'content' => 'Python script should assert true without any code block.'}}]} } + + it 'raises InvalidTaskDescription' do + expect { gpt_generate_tests }.to raise_error(Gpt::Error::InvalidTaskDescription) + end + end + + context 'when OpenAI is not responding' do + before do + allow(openai_client).to receive(:chat).and_raise(Faraday::Error) + end + + it 'raises InternalServerError' do + expect { gpt_generate_tests }.to raise_error(Gpt::Error::InternalServerError) + end + end + + context 'when the network connection is broken' do + before do + allow(openai_client).to receive(:chat).and_raise(EOFError) + end + + it 'raises an error' do + expect { gpt_generate_tests }.to raise_error(Gpt::Error) + end + end + + context 'when API key is invalid' do + let(:openai_api_key) { 'invalid_api_key' } + + before do + allow(openai_client).to receive(:chat).and_raise(Faraday::UnauthorizedError) + end + + it 'raises InvalidApiKeyError' do + expect { gpt_generate_tests }.to raise_error(Gpt::Error::InvalidApiKey) + end + end + end +end diff --git a/spec/services/gpt_service/validate_api_key_spec.rb b/spec/services/gpt_service/validate_api_key_spec.rb new file mode 100644 index 000000000..58938df2e --- /dev/null +++ b/spec/services/gpt_service/validate_api_key_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GptService::ValidateApiKey do + let(:openai_api_key) { 'valid_api_key' } + let(:openai_client) { OpenAI::Client.new(access_token: openai_api_key) } + let(:openai_models) { instance_double(OpenAI::Models, list: {'data' => models_list}) } + let(:models_list) { [{'id' => 'model-id'}] } + + before do + allow(OpenAI::Client).to receive(:new).and_return(openai_client) + allow(openai_client).to receive(:models).and_return(openai_models) + end + + describe '.new' do + subject(:validate_api_key) { described_class.new(openai_api_key:) } + + it 'assigns the client for OpenAI' do + expect(validate_api_key.instance_variable_get(:@client)).to be openai_client + end + + it 'stores the OpenAI API key in the client' do + expect(validate_api_key.instance_variable_get(:@client).access_token).to eq openai_api_key + end + + context 'when API key is missing' do + let(:openai_api_key) { nil } + + it 'raises InvalidApiKeyError' do + expect { validate_api_key }.to raise_error(Gpt::Error::InvalidApiKey) + end + end + end + + describe '#call' do + subject(:validate_api_key) { described_class.call(openai_api_key:) } + + it 'does not raise an error' do + expect { validate_api_key }.not_to raise_error + end + + context 'when model list is empty' do + let(:models_list) {} + + it 'raises correct error' do + expect { validate_api_key }.to raise_error(Gpt::Error::InvalidApiKey) + end + end + + context 'when API key is invalid' do + let(:openai_api_key) { 'invalid_api_key' } + + before do + allow(openai_models).to receive(:list).and_raise(Faraday::UnauthorizedError) + end + + it 'raises InvalidApiKeyError' do + expect { validate_api_key }.to raise_error(Gpt::Error::InvalidApiKey) + end + end + + context 'when OpenAI is not responding' do + before do + allow(openai_models).to receive(:list).and_raise(Faraday::Error) + end + + it 'raises InternalServerError' do + expect { validate_api_key }.to raise_error(Gpt::Error::InternalServerError) + end + end + + context 'when the network connection is broken' do + before do + allow(openai_models).to receive(:list).and_raise(EOFError) + end + + it 'raises an error' do + expect { validate_api_key }.to raise_error(Gpt::Error) + end + end + end +end diff --git a/spec/services/task_service/gpt_generate_tests_spec.rb b/spec/services/task_service/gpt_generate_tests_spec.rb deleted file mode 100644 index 85d135e53..000000000 --- a/spec/services/task_service/gpt_generate_tests_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# spec/services/task_service/gpt_generate_tests_spec.rb -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe TaskService::GptGenerateTests do - describe '.new' do - subject(:gpt_generate_tests_service) { described_class.new(task:) } - - let(:programming_language) { build(:programming_language, :ruby) } - let(:task) { build(:task, description: 'Sample Task', programming_language:) } - - it 'assigns task' do - expect(gpt_generate_tests_service.instance_variable_get(:@task)).to be task - end - - context 'when language is missing' do - let(:task) { build(:task, description: 'Sample Task', programming_language: nil) } - - it 'raises MissingLanguageError' do - expect { gpt_generate_tests_service }.to raise_error(Gpt::MissingLanguageError) - end - end - end - - describe '#call' do - subject(:gpt_generate_tests) { described_class.call(task:) } - - let(:programming_language) { create(:programming_language, :python) } - let(:task) { create(:task, description: 'Create a Python script.', programming_language:) } - let(:mock_client) { instance_double(OpenAI::Client) } - - before do - allow(OpenAI::Client).to receive(:new).and_return(mock_client) - end - - context 'when the response includes valid code blocks' do - before do - allow(mock_client).to receive(:chat).and_return('choices' => [{'message' => {'content' => "```Python\ndef test_script():\n assert true```"}}]) - gpt_generate_tests - end - - it 'creates a test file related to the task' do - test_file = task.reload.tests.last.files.first - expect(test_file).to have_attributes( - content: "def test_script():\n assert true", - name: 'test.py' - ) - end - - it 'creates a test instance related to the task' do - test = task.reload.tests.last - expect(test).to have_attributes(title: I18n.t('tests.model.generated_test')) - end - end - - context 'when the response does not contain backticks' do - before do - allow(mock_client).to receive(:chat).and_return({'choices' => [{'message' => {'content' => 'Python script should assert true without any code block.'}}]}) - end - - it 'raises InvalidTaskDescription when response does not contain backticks' do - expect { gpt_generate_tests }.to raise_error(Gpt::InvalidTaskDescription) - end - end - end -end