diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/AGENTS.md b/AGENTS.md index 67775e4..c8f9180 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,12 +42,15 @@ A Rails app for managing engineering design doc review, purpose-built for AI-gen ## Testing -- **Minitest** — not RSpec -- Run the full suite: `bin/rails test` -- Test files mirror app structure: `test/models/`, `test/controllers/`, `test/services/` -- Use **fixtures** (YAML in `test/fixtures/`) — not factories -- Every model, service object, and controller action should have tests -- Fixtures use hardcoded UUIDs for stable cross-references +- **RSpec** with `rspec-rails` +- Run the full suite: `bundle exec rspec` +- Spec files mirror app structure: `spec/models/`, `spec/requests/`, `spec/services/`, `spec/helpers/` +- Use **FactoryBot** (`factory_bot_rails`) — factories live in `spec/factories/` +- Every model, service object, and controller action should have specs +- UUID primary keys are auto-assigned by `ApplicationRecord#assign_uuid` — do **not** set `id` in factories +- Factories derive associations from parent objects (e.g., `organization { plan.organization }`) to keep data consistent +- FactoryBot syntax methods (`create`, `build`) are included globally via `config.include FactoryBot::Syntax::Methods` +- `sign_in_as(user)` helper is defined in `spec/rails_helper.rb` for request specs ## Seeds diff --git a/Gemfile b/Gemfile index 59de984..0712719 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.1" -gem "minitest", "~> 5.25" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use mysql as the database for Active Record @@ -73,6 +72,8 @@ group :development do end group :test do + gem "rspec-rails" + gem "factory_bot_rails" # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" diff --git a/Gemfile.lock b/Gemfile.lock index f1bea96..745baf1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,6 +129,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) diffy (3.4.4) dotenv (3.2.0) drb (2.2.3) @@ -138,6 +139,11 @@ GEM et-orbi (1.4.0) tzinfo event_stream_parser (1.0.0) + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json @@ -345,6 +351,23 @@ GEM actionpack (>= 7.0) railties (>= 7.0) rexml (3.4.4) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.3) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.7) rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -469,16 +492,17 @@ DEPENDENCIES commonmarker debug diffy + factory_bot_rails image_processing (~> 1.2) importmap-rails jbuilder kamal - minitest (~> 5.25) mysql2 (~> 0.5) propshaft puma (>= 5.0) rack-attack rails (~> 8.1.1) + rspec-rails rubocop-rails-omakase ruby-openai selenium-webdriver @@ -532,6 +556,7 @@ CHECKSUMS csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 diffy (3.4.4) sha256=79384ab5ca82d0e115b2771f0961e27c164c456074bd2ec46b637ebf7b6e47e3 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 @@ -540,6 +565,8 @@ CHECKSUMS erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8 + factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c @@ -628,6 +655,11 @@ CHECKSUMS reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-rails (8.0.3) sha256=b0a440e7a10700317d898a014852e26660867298c4076dbc3baa99c768b79dc1 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 03090a5..6a2fd14 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -914,6 +914,41 @@ img, svg { min-width: 200px; } +/* Dropdown */ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown__menu { + position: absolute; + right: 0; + top: 100%; + margin-top: var(--space-xs); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + min-width: 200px; + z-index: 100; +} + +.dropdown__item { + display: block; + width: 100%; + padding: var(--space-sm) var(--space-md); + text-align: left; + background: none; + border: none; + cursor: pointer; + font-size: var(--font-sm); + color: var(--color-text); +} + +.dropdown__item:hover { + background: var(--color-bg-muted); +} + /* Page header */ .page-header__subtitle { color: var(--color-text-muted); diff --git a/app/controllers/automated_reviews_controller.rb b/app/controllers/automated_reviews_controller.rb new file mode 100644 index 0000000..495d31a --- /dev/null +++ b/app/controllers/automated_reviews_controller.rb @@ -0,0 +1,28 @@ +class AutomatedReviewsController < ApplicationController + before_action :scope_to_organization + before_action :set_plan + before_action :set_reviewer, only: [:create] + + def create + authorize!(@plan, :update?) + + AutomatedReviewJob.perform_later( + plan_id: @plan.id, + reviewer_id: @reviewer.id, + plan_version_id: @plan.current_plan_version_id, + triggered_by: current_user + ) + + redirect_to plan_path(@plan), notice: "#{@reviewer.name} review queued." + end + + private + + def set_plan + @plan = @organization.plans.find(params[:plan_id]) + end + + def set_reviewer + @reviewer = @organization.automated_plan_reviewers.enabled.find(params[:reviewer_id]) + end +end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 32be818..ca560b6 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -48,6 +48,9 @@ def update_status new_status = params[:status] if Plan::STATUSES.include?(new_status) && @plan.update(status: new_status) broadcast_plan_update(@plan) + if @plan.saved_change_to_status? + Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: new_status, triggered_by: current_user) + end redirect_to plan_path(@plan), notice: "Status updated to #{new_status}." else redirect_to plan_path(@plan), alert: "Invalid status." diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000..5e53247 --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu"] + + toggle() { + const menu = this.menuTarget + menu.style.display = menu.style.display === "none" ? "" : "none" + } + + close(event) { + if (!this.element.contains(event.target)) { + this.menuTarget.style.display = "none" + } + } + + connect() { + this._closeHandler = this.close.bind(this) + document.addEventListener("click", this._closeHandler) + } + + disconnect() { + document.removeEventListener("click", this._closeHandler) + } +} diff --git a/app/jobs/automated_review_job.rb b/app/jobs/automated_review_job.rb new file mode 100644 index 0000000..c03fdc4 --- /dev/null +++ b/app/jobs/automated_review_job.rb @@ -0,0 +1,71 @@ +class AutomatedReviewJob < ApplicationJob + queue_as :default + + discard_on AiProviders::OpenAi::Error + discard_on AiProviders::Anthropic::Error + + def perform(plan_id:, reviewer_id:, plan_version_id:, triggered_by: nil) + plan = Plan.find(plan_id) + reviewer = AutomatedPlanReviewer.find(reviewer_id) + version = PlanVersion.find(plan_version_id) + + return unless reviewer.enabled? + + response = call_ai_provider(reviewer, version.content_markdown) + feedback_items = Plans::ReviewResponseParser.call(response, plan_content: version.content_markdown) + + create_review_comments(plan, version, reviewer, feedback_items, triggered_by) + end + + private + + def call_ai_provider(reviewer, content) + system_prompt = Plans::ReviewPromptFormatter.call(reviewer_prompt: reviewer.prompt_text) + provider_class = resolve_provider(reviewer.ai_provider) + provider_class.call( + system_prompt: system_prompt, + user_content: content, + model: reviewer.ai_model + ) + end + + def resolve_provider(provider_name) + case provider_name + when "openai" then AiProviders::OpenAi + when "anthropic" then AiProviders::Anthropic + else raise ArgumentError, "Unknown AI provider: #{provider_name}" + end + end + + def create_review_comments(plan, version, reviewer, feedback_items, triggered_by) + created_by = triggered_by || plan.created_by_user + + feedback_items.each do |item| + thread = plan.comment_threads.create!( + organization: plan.organization, + plan_version: version, + created_by_user: created_by, + anchor_text: item[:anchor_text], + status: "open" + ) + + thread.comments.create!( + organization: plan.organization, + author_type: AutomatedPlanReviewer::ACTOR_TYPE, + author_id: reviewer.id, + body_markdown: item[:comment] + ) + + broadcast_new_thread(plan, thread) + end + end + + def broadcast_new_thread(plan, thread) + Turbo::StreamsChannel.broadcast_prepend_to( + plan, + target: "comment-threads", + partial: "comment_threads/thread", + locals: { thread: thread, plan: plan } + ) + end +end diff --git a/app/services/ai_providers/anthropic.rb b/app/services/ai_providers/anthropic.rb new file mode 100644 index 0000000..572334f --- /dev/null +++ b/app/services/ai_providers/anthropic.rb @@ -0,0 +1,19 @@ +module AiProviders + class Anthropic + def self.call(system_prompt:, user_content:, model: "claude-sonnet-4-20250514") + new(system_prompt:, user_content:, model:).call + end + + def initialize(system_prompt:, user_content:, model:) + @system_prompt = system_prompt + @user_content = user_content + @model = model + end + + def call + raise Error, "Anthropic provider not yet implemented. Use OpenAI for now." + end + + class Error < StandardError; end + end +end diff --git a/app/services/ai_providers/open_ai.rb b/app/services/ai_providers/open_ai.rb new file mode 100644 index 0000000..5f90c4b --- /dev/null +++ b/app/services/ai_providers/open_ai.rb @@ -0,0 +1,42 @@ +module AiProviders + class OpenAi + def self.call(system_prompt:, user_content:, model: "gpt-4o") + new(system_prompt:, user_content:, model:).call + end + + def initialize(system_prompt:, user_content:, model:) + @system_prompt = system_prompt + @user_content = user_content + @model = model + end + + def call + client = OpenAI::Client.new(access_token: api_key) + + response = client.chat( + parameters: { + model: @model, + messages: [ + { role: "system", content: @system_prompt }, + { role: "user", content: @user_content } + ] + } + ) + + content = response.dig("choices", 0, "message", "content") + raise Error, "No response content from OpenAI" if content.blank? + + content + end + + private + + def api_key + key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"] + raise Error, "OpenAI API key not configured" if key.blank? + key + end + + class Error < StandardError; end + end +end diff --git a/app/services/plans/review_prompt_formatter.rb b/app/services/plans/review_prompt_formatter.rb new file mode 100644 index 0000000..e98e134 --- /dev/null +++ b/app/services/plans/review_prompt_formatter.rb @@ -0,0 +1,23 @@ +module Plans + class ReviewPromptFormatter + RESPONSE_FORMAT_INSTRUCTIONS = <<~INSTRUCTIONS.freeze + You MUST respond with a JSON array of feedback items. Each item is an object with two keys: + - "anchor_text": An exact substring copied verbatim from the plan document that this feedback applies to. Keep it short (a phrase or single sentence). Must match the plan text exactly. Use null for general feedback not tied to specific text. + - "comment": Your feedback in Markdown. Be concise and actionable. + + Example response: + ```json + [ + {"anchor_text": "API tokens scoped to a user", "comment": "Consider adding token expiration by default. Long-lived tokens without expiry are a common security risk."}, + {"anchor_text": null, "comment": "Overall the plan looks solid. One general concern: there's no mention of audit logging for administrative actions."} + ] + ``` + + Return ONLY the JSON array. No other text before or after it. + INSTRUCTIONS + + def self.call(reviewer_prompt:) + "#{reviewer_prompt}\n\n#{RESPONSE_FORMAT_INSTRUCTIONS}" + end + end +end diff --git a/app/services/plans/review_response_parser.rb b/app/services/plans/review_response_parser.rb new file mode 100644 index 0000000..3a7e5d1 --- /dev/null +++ b/app/services/plans/review_response_parser.rb @@ -0,0 +1,63 @@ +module Plans + class ReviewResponseParser + def self.call(response_text, plan_content:) + new(response_text, plan_content:).call + end + + def initialize(response_text, plan_content:) + @response_text = response_text + @plan_content = plan_content + end + + def call + items = parse_json + items.filter_map { |item| normalize_item(item) } + end + + private + + def parse_json + json_text = extract_json_from_response + parsed = JSON.parse(json_text) + + unless parsed.is_a?(Array) + return fallback_single_comment + end + + parsed + rescue JSON::ParserError + fallback_single_comment + end + + def extract_json_from_response + # Strip markdown code fences if present + text = @response_text.strip + if text.start_with?("```") + text = text.sub(/\A```(?:json)?\s*\n?/, "").sub(/\n?```\s*\z/, "") + end + text + end + + def normalize_item(item) + return nil unless item.is_a?(Hash) + + anchor = item["anchor_text"].presence + comment = item["comment"].to_s.strip + + return nil if comment.blank? + + # Verify anchor_text actually exists in the plan content + if anchor && !@plan_content.include?(anchor) + # Anchor doesn't match — demote to unanchored with the quote in the comment + comment = "> #{anchor}\n\n#{comment}" + anchor = nil + end + + { anchor_text: anchor, comment: comment } + end + + def fallback_single_comment + [{ "anchor_text" => nil, "comment" => @response_text }] + end + end +end diff --git a/app/services/plans/trigger_automated_reviews.rb b/app/services/plans/trigger_automated_reviews.rb new file mode 100644 index 0000000..03e209c --- /dev/null +++ b/app/services/plans/trigger_automated_reviews.rb @@ -0,0 +1,30 @@ +module Plans + class TriggerAutomatedReviews + def self.call(plan:, new_status:, triggered_by:) + new(plan:, new_status:, triggered_by:).call + end + + def initialize(plan:, new_status:, triggered_by:) + @plan = plan + @new_status = new_status + @triggered_by = triggered_by + end + + def call + version_id = @plan.current_plan_version_id + return unless version_id + + reviewers = @plan.organization.automated_plan_reviewers.enabled + reviewers.each do |reviewer| + next unless reviewer.triggers_on_status?(@new_status) + + AutomatedReviewJob.perform_later( + plan_id: @plan.id, + reviewer_id: reviewer.id, + plan_version_id: version_id, + triggered_by: @triggered_by + ) + end + end + end +end diff --git a/app/views/plans/_header.html.erb b/app/views/plans/_header.html.erb index 2ae5950..c51c086 100644 --- a/app/views/plans/_header.html.erb +++ b/app/views/plans/_header.html.erb @@ -6,6 +6,21 @@
+ <% if plan.organization.automated_plan_reviewers.enabled.any? %> + + <% end %> <%= link_to "History (v#{plan.current_revision})", plan_versions_path(plan), class: "btn btn--secondary" %>
diff --git a/config/routes.rb b/config/routes.rb index fce87c3..8ebe689 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ resources :plans, only: [:index, :show, :edit, :update] do patch :update_status, on: :member resources :versions, controller: "plan_versions", only: [:index, :show] + resources :automated_reviews, only: [:create] resources :comment_threads, only: [:create] do member do patch :resolve diff --git a/spec/controllers/automated_reviews_controller_spec.rb b/spec/controllers/automated_reviews_controller_spec.rb new file mode 100644 index 0000000..654ff4d --- /dev/null +++ b/spec/controllers/automated_reviews_controller_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe AutomatedReviewsController, type: :controller do + let(:org) { create(:organization) } + let(:user) { create(:user, organization: org) } + let(:plan) { create(:plan, organization: org, created_by_user: user) } + let!(:reviewer) { create(:automated_plan_reviewer, organization: org, enabled: true) } + + before do + session[:user_id] = user.id + end + + describe "POST #create" do + it "enqueues an AutomatedReviewJob" do + expect { + post :create, params: { plan_id: plan.id, reviewer_id: reviewer.id } + }.to have_enqueued_job(AutomatedReviewJob).with( + plan_id: plan.id, + reviewer_id: reviewer.id, + plan_version_id: plan.current_plan_version_id, + triggered_by: user + ) + end + + it "redirects to the plan with a notice" do + post :create, params: { plan_id: plan.id, reviewer_id: reviewer.id } + expect(response).to redirect_to(plan_path(plan)) + expect(flash[:notice]).to include(reviewer.name) + end + + it "returns 404 for disabled reviewers" do + reviewer.update!(enabled: false) + expect { + post :create, params: { plan_id: plan.id, reviewer_id: reviewer.id } + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/factories/api_tokens.rb b/spec/factories/api_tokens.rb new file mode 100644 index 0000000..f8333e5 --- /dev/null +++ b/spec/factories/api_tokens.rb @@ -0,0 +1,22 @@ +FactoryBot.define do + factory :api_token do + organization + user { association(:user, organization: organization) } + sequence(:name) { |n| "Token #{n}" } + token_digest { Digest::SHA256.hexdigest(SecureRandom.hex(32)) } + + transient do + raw_token { nil } + end + + after(:build) do |token, evaluator| + if evaluator.raw_token + token.token_digest = Digest::SHA256.hexdigest(evaluator.raw_token) + end + end + + trait :revoked do + revoked_at { 1.day.ago } + end + end +end diff --git a/spec/factories/automated_plan_reviewers.rb b/spec/factories/automated_plan_reviewers.rb new file mode 100644 index 0000000..dbe2726 --- /dev/null +++ b/spec/factories/automated_plan_reviewers.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :automated_plan_reviewer do + organization + sequence(:key) { |n| "reviewer-#{n}" } + sequence(:name) { |n| "Reviewer #{n}" } + prompt_text { "You are a reviewer. Review the plan." } + enabled { true } + trigger_statuses { [] } + ai_provider { "openai" } + ai_model { "gpt-4o" } + end +end diff --git a/spec/factories/comment_threads.rb b/spec/factories/comment_threads.rb new file mode 100644 index 0000000..6fb943e --- /dev/null +++ b/spec/factories/comment_threads.rb @@ -0,0 +1,19 @@ +FactoryBot.define do + factory :comment_thread do + plan + organization { plan.organization } + plan_version { plan.current_plan_version } + created_by_user { association(:user, organization: plan.organization) } + status { "open" } + out_of_date { false } + + trait :with_anchor do + anchor_text { "some anchor text" } + end + + trait :resolved do + status { "resolved" } + association :resolved_by_user, factory: :user + end + end +end diff --git a/spec/factories/comments.rb b/spec/factories/comments.rb new file mode 100644 index 0000000..9522217 --- /dev/null +++ b/spec/factories/comments.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :comment do + comment_thread + organization { comment_thread.organization } + author_type { "human" } + author_id { association(:user, organization: organization).id } + body_markdown { "A comment body." } + end +end diff --git a/spec/factories/edit_leases.rb b/spec/factories/edit_leases.rb new file mode 100644 index 0000000..e3e4b0e --- /dev/null +++ b/spec/factories/edit_leases.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :edit_lease do + plan + organization + holder_type { "local_agent" } + holder_id { SecureRandom.uuid } + lease_token_digest { Digest::SHA256.hexdigest(SecureRandom.hex(32)) } + expires_at { 5.minutes.from_now } + last_heartbeat_at { Time.current } + end +end diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb new file mode 100644 index 0000000..4b215ca --- /dev/null +++ b/spec/factories/organizations.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :organization do + sequence(:name) { |n| "Organization #{n}" } + sequence(:slug) { |n| "org-#{n}" } + allowed_email_domains { ["example.com"] } + end +end diff --git a/spec/factories/plan_versions.rb b/spec/factories/plan_versions.rb new file mode 100644 index 0000000..e7e8be5 --- /dev/null +++ b/spec/factories/plan_versions.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :plan_version do + plan + organization { plan.organization } + sequence(:revision) { |n| n } + content_markdown { "# Plan Content\n\nSome content here." } + actor_type { "human" } + actor_id { nil } + end +end diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb new file mode 100644 index 0000000..cae0463 --- /dev/null +++ b/spec/factories/plans.rb @@ -0,0 +1,25 @@ +FactoryBot.define do + factory :plan do + organization + created_by_user { association(:user, organization: organization) } + sequence(:title) { |n| "Plan #{n}" } + status { "brainstorm" } + tags { [] } + metadata { {} } + + after(:create) do |plan| + unless plan.current_plan_version + version = create(:plan_version, plan: plan, organization: plan.organization, revision: 1, actor_id: plan.created_by_user_id) + plan.update_columns(current_plan_version_id: version.id, current_revision: 1) + end + end + + trait :considering do + status { "considering" } + end + + trait :brainstorm do + status { "brainstorm" } + end + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..d9a08ab --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :user do + organization + sequence(:email) { |n| "user#{n}@example.com" } + sequence(:name) { |n| "User #{n}" } + org_role { "member" } + + trait :admin do + org_role { "admin" } + end + end +end diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb new file mode 100644 index 0000000..dd8940d --- /dev/null +++ b/spec/helpers/markdown_helper_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe MarkdownHelper, type: :helper do + describe "#render_markdown" do + it "converts markdown to HTML" do + html = helper.render_markdown("# Hello\n\n**bold** text") + expect(html).to include("

") + expect(html).to include("bold") + expect(html).to include("markdown-rendered") + end + + it "sanitizes dangerous HTML" do + html = helper.render_markdown('') + expect(html).not_to match(/') + expect(html).not_to match(/') - assert_no_match(/') - assert_no_match(/