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:
- Überschreiben Sie die Aufgabe in der externen App. Dadurch werden alle Änderungen, die in CodeHarbor vorgenommen wurden, auf die externe App übertragen.
Vorsicht: Dadurch werden alle potenziellen Änderungen in der externen App überschrieben. Dadurch wird die Aufgabe geändert (und möglicherweise zerstört), auch wenn sie derzeit in einem Kurs verwendet wird. - Erstellen Sie eine neue Aufgabe, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.
'
task_found_no_right: 'In der externen App wurde eine entsprechende Aufgabe gefunden, aber Sie haben keine Rechte, sie zu bearbeiten. Sie können: - Eine neue Aufgabe erstellen, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.
'
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: - Overwrite the task on the external app. This will transfer all changes made on CodeHarbor to the external app.
Careful: This will overwrite all potential changes made on the external app. This will change (and might break) the task, even if it is currently in use by a course. - Create a new task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new exercise to the external app.
'
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: - Create a new task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new task to the external app.
'
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