Merge remote-tracking branch 'origin/main' into ruby-3-4
Some checks failed
Add copyright notice / copyright_notice (pull_request) Successful in 1m13s
Check usage of free licenses / check-licenses (pull_request) Failing after 1m13s
Run unit tests / unit_tests (pull_request) Failing after 1m27s
Build Nginx-based docker image / build-static-assets (push) Failing after 7m8s

This commit is contained in:
Manuel Bustillo 2024-12-29 14:26:35 +01:00
commit dcea73b64d
97 changed files with 882 additions and 355 deletions

View File

@ -1,8 +1,6 @@
name: Build Nginx-based docker image name: Build Nginx-based docker image
on: on:
push: push:
branches:
- main
concurrency: concurrency:
group: ${{ github.ref }} group: ${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@ -25,12 +23,21 @@ jobs:
username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }} username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
password: ${{ secrets.PRIVATE_REGISTRY_TOKEN }} password: ${{ secrets.PRIVATE_REGISTRY_TOKEN }}
- name: Build and push - name: Build and push intermediate stages (build)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} target: build
tags: | push: ${{ github.ref == 'refs/heads/main' }}
${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:build
cache-from: type=registry,ref=user/app:latest cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:build
cache-to: type=inline
- name: Build and push (final)
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest
cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest
cache-to: type=inline cache-to: type=inline

View File

@ -24,6 +24,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- uses: ruby/setup-ruby@v1.202.0 - uses: ruby/setup-ruby@v1.202.0
- run: bundle install - run: bundle install
- run: bundle exec rubocop --force-exclusion --parallel
- name: Wait until Postgres is ready to accept connections - name: Wait until Postgres is ready to accept connections
run: | run: |
apt-get update && apt-get install -f -y postgresql-client apt-get update && apt-get install -f -y postgresql-client

29
.rubocop.yml Normal file
View File

@ -0,0 +1,29 @@
require:
- rubocop-rails
- rubocop-factory_bot
- rubocop-rspec
- rubocop-rspec_rails
AllCops:
NewCops: enable
Exclude:
- 'db/**/*'
- 'config/**/*'
- 'script/**/*'
- 'bin/*'
- '*.yml'
Layout/LineLength:
Max: 120
RSpec/ExampleLength:
Enabled: false
Metrics/ModuleLength:
Enabled: false
RSpec/MultipleMemoizedHelpers:
Enabled: false
Style/Documentation:
Enabled: false
Metrics/MethodLength:
Max: 20
Rails/SkipsModelValidations:
Enabled: false
Metrics/AbcSize:
Enabled: false

20
Gemfile
View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.1' ruby '3.4.1'
@ -15,15 +17,15 @@ gem 'stimulus-rails'
gem 'turbo-rails' gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[windows jruby] gem 'tzinfo-data', platforms: %i[windows jruby]
gem 'acts_as_tenant'
gem 'faker' gem 'faker'
gem 'httparty'
gem 'jsonapi-rails' gem 'jsonapi-rails'
gem 'pluck_to_hash'
gem 'rack-cors' gem 'rack-cors'
gem 'react-rails' gem 'react-rails'
gem 'rubytree'
gem 'acts_as_tenant'
gem 'httparty'
gem 'rswag' gem 'rswag'
gem 'pluck_to_hash' gem 'rubytree'
group :development, :test do group :development, :test do
gem 'annotaterb' gem 'annotaterb'
@ -36,12 +38,16 @@ group :development, :test do
end end
group :development do group :development do
gem 'rubocop'
gem 'web-console'
gem 'letter_opener_web' gem 'letter_opener_web'
gem 'rubocop'
gem 'rubocop-factory_bot', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop-rspec_rails', require: false
gem 'web-console'
end end
gem 'chroma' gem 'chroma'
gem 'solid_queue', '~> 1.0' gem 'solid_queue', '~> 1.0'
gem "devise", "~> 4.9" gem 'devise', '~> 4.9'

View File

@ -85,7 +85,7 @@ GEM
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.0) benchmark (0.4.0)
bigdecimal (3.1.8) bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
@ -339,6 +339,18 @@ GEM
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.36.2) rubocop-ast (1.36.2)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.28.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rubytree (2.1.0) rubytree (2.1.0)
json (~> 2.0, > 2.3.1) json (~> 2.0, > 2.3.1)
@ -346,7 +358,7 @@ GEM
securerandom (0.4.1) securerandom (0.4.1)
shoulda-matchers (6.4.0) shoulda-matchers (6.4.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
solid_queue (1.1.0) solid_queue (1.1.2)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
@ -426,6 +438,10 @@ DEPENDENCIES
rspec-rails (~> 7.1.0) rspec-rails (~> 7.1.0)
rswag rswag
rubocop rubocop
rubocop-factory_bot
rubocop-rails
rubocop-rspec
rubocop-rspec_rails
rubytree rubytree
shoulda-matchers (~> 6.0) shoulda-matchers (~> 6.0)
solid_queue (~> 1.0) solid_queue (~> 1.0)
@ -456,7 +472,7 @@ CHECKSUMS
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099
benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a
bigdecimal (3.1.8) sha256=a89467ed5a44f8ae01824af49cbc575871fa078332e8f77ea425725c1ffe27be bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
@ -561,12 +577,16 @@ CHECKSUMS
rswag-ui (2.16.0) sha256=a1f49e927dceda92e6e6e7c1000f1e217ee66c565f69e28131dc98b33cd3a04f rswag-ui (2.16.0) sha256=a1f49e927dceda92e6e6e7c1000f1e217ee66c565f69e28131dc98b33cd3a04f
rubocop (1.69.2) sha256=762fb0f30a379bf6054588d39f1815a2a1df8f067bc0337d3fe500e346924bb8 rubocop (1.69.2) sha256=762fb0f30a379bf6054588d39f1815a2a1df8f067bc0337d3fe500e346924bb8
rubocop-ast (1.36.2) sha256=566405b7f983eb9aa3b91d28aca6bc6566e356a97f59e89851dd910aef1dd1ca rubocop-ast (1.36.2) sha256=566405b7f983eb9aa3b91d28aca6bc6566e356a97f59e89851dd910aef1dd1ca
rubocop-factory_bot (2.26.1) sha256=8de13cd4edcee5ca800f255188167ecef8dbfc3d1fae9f15734e9d2e755392aa
rubocop-rails (2.28.0) sha256=4967bed9ea13e6dcab566fea4265a6dd0381db739b305e48930aba1282da2715
rubocop-rspec (3.3.0) sha256=79e1b281a689044d1516fefbc52e2e6c06cd367c25ebeaf06a7a198e9071cd7d
rubocop-rspec_rails (2.30.0) sha256=888112e83f9d7ef7ad2397e9d69a0b9614a4bae24f072c399804a180f80c4c46
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
rubytree (2.1.0) sha256=30e8759ba060dff0dabf7e40cbaaa4df892fa34cbe9f1b3fbb00e83a3f321e4b rubytree (2.1.0) sha256=30e8759ba060dff0dabf7e40cbaaa4df892fa34cbe9f1b3fbb00e83a3f321e4b
rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
shoulda-matchers (6.4.0) sha256=9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0 shoulda-matchers (6.4.0) sha256=9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0
solid_queue (1.1.0) sha256=55c7b1d65f2943ec8f3deee36fcb0cae91d5fa60410b294775db9da22be8353c solid_queue (1.1.2) sha256=178c9396d1cf0dac595c7508da90ddb397d25848ca007b5c5ed48e6ac6fc360c
sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97
sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake, # Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application" require_relative 'config/application'
Rails.application.load_tasks Rails.application.load_tasks

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module ApplicationCable module ApplicationCable
class Channel < ActionCable::Channel::Base class Channel < ActionCable::Channel::Base
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module ApplicationCable module ApplicationCable
class Connection < ActionCable::Connection::Base class Connection < ActionCable::Connection::Base
end end

View File

@ -0,0 +1,41 @@
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class AffinitiesController < ApplicationController
before_action :set_group
def index
overridden = @group.affinities.each_with_object({}) do |affinity, acc|
acc[affinity.another_group(@group).id] = affinity.discomfort
end
Group.where.not(id: @group.id)
.pluck(:id)
.index_with { |group_id| GroupAffinity::MAX_DISCOMFORT - (overridden[group_id] || GroupAffinity::NEUTRAL) }
.then { |affinities| render json: affinities }
end
def bulk_update
affinities = params.expect(affinities: [%i[group_id affinity]]).map(&:to_h).map do |affinity|
{
group_a_id: @group.id,
group_b_id: affinity[:group_id],
discomfort: GroupAffinity::MAX_DISCOMFORT - affinity[:affinity]
}
end
GroupAffinity.upsert_all(affinities)
render json: {}, status: :ok
rescue ActiveRecord::InvalidForeignKey
render json: { error: 'At least one of the group IDs provided does not exist.' }, status: :bad_request
rescue ActiveRecord::StatementInvalid
render json: { error: 'Invalid group ID or discomfort provided.' }, status: :bad_request
end
private
def set_group
@group = Group.find(params[:group_id])
end
end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
set_current_tenant_through_filter set_current_tenant_through_filter
before_action :set_tenant before_action :set_tenant
@ -40,7 +42,7 @@ class ApplicationController < ActionController::Base
end end
def captcha_params def captcha_params
params.expect(captcha: [:id, :answer]) params.expect(captcha: %i[id answer])
end end
def default_url_options(options = {}) def default_url_options(options = {})
@ -53,7 +55,7 @@ class ApplicationController < ActionController::Base
def development_swagger? def development_swagger?
Rails.env.test? || Rails.env.test? ||
Rails.env.development? && request.headers['referer']&.include?('/api-docs/index.html') (Rails.env.development? && request.headers['referer']&.include?('/api-docs/index.html'))
end end
def set_csrf_cookie def set_csrf_cookie

View File

@ -1,10 +1,12 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class CaptchaController < ApplicationController class CaptchaController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_before_action :set_tenant skip_before_action :set_tenant
def create def create
id = LibreCaptcha.new.get_id id = LibreCaptcha.new.id
render json: { render json: {
id:, id:,
media_url: media_captcha_index_url(id:) media_url: media_captcha_index_url(id:)

View File

@ -1,12 +1,14 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class ExpensesController < ApplicationController class ExpensesController < ApplicationController
def summary def summary
render json: Expenses::TotalQuery.new.call render json: Expenses::TotalQuery.new.call
end end
def index def index
render json: Expense.all.order(pricing_type: :asc, amount: :desc).as_json(only: %i[id name amount pricing_type]) render json: Expense.order(pricing_type: :asc, amount: :desc).as_json(only: %i[id name amount pricing_type])
end end
def create def create

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class GroupsController < ApplicationController class GroupsController < ApplicationController
def index def index
query_result = Groups::SummaryQuery.new.call.as_json.map(&:deep_symbolize_keys).map do |group| query_result = Groups::SummaryQuery.new.call.as_json.map(&:deep_symbolize_keys).map do |group|
@ -39,6 +41,6 @@ class GroupsController < ApplicationController
end end
def group_params def group_params
params.expect(group: [:name, :icon, :color]) params.expect(group: %i[name icon color])
end end
end end

View File

@ -1,10 +1,12 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'csv' require 'csv'
class GuestsController < ApplicationController class GuestsController < ApplicationController
def index def index
render json: Guest.all.includes(:group) render json: Guest.includes(:group)
.left_joins(:group) .left_joins(:group)
.order('groups.name' => :asc, name: :asc) .order('groups.name' => :asc, name: :asc)
.as_json(only: %i[id name status], include: { group: { only: %i[id name] } }) .as_json(only: %i[id name status], include: { group: { only: %i[id name] } })

View File

@ -1,29 +1,43 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class SummaryController < ApplicationController class SummaryController < ApplicationController
def index def index
expense_summary = Expenses::TotalQuery.new(wedding: ActsAsTenant.current_tenant).call
guest_summary = Guest.group(:status).count
render json: { render json: {
expenses: { expenses:,
projected: { guests:
total: expense_summary['total_projected'], }
guests: expense_summary['projected_guests'] end
},
confirmed: { private
total: expense_summary['total_confirmed'],
guests: expense_summary['confirmed_guests'] def guests
}, guest_summary = Guest.group(:status).count
status: {
paid: 0 {
} total: guest_summary.except('considered').values.sum,
confirmed: guest_summary['confirmed'].to_i,
declined: guest_summary['declined'].to_i,
tentative: guest_summary['tentative'].to_i,
invited: guest_summary['invited'].to_i
}
end
def expenses
expense_summary = Expenses::TotalQuery.new(wedding: ActsAsTenant.current_tenant).call
{
projected: {
total: expense_summary['total_projected'],
guests: expense_summary['projected_guests']
}, },
guests: { confirmed: {
total: guest_summary.except('considered').values.sum, total: expense_summary['total_confirmed'],
confirmed: guest_summary['confirmed'].to_i, guests: expense_summary['confirmed_guests']
declined: guest_summary['declined'].to_i, },
tentative: guest_summary['tentative'].to_i, status: {
invited: guest_summary['invited'].to_i paid: 0
} }
} }
end end

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class TablesArrangementsController < ApplicationController class TablesArrangementsController < ApplicationController
def index def index
render json: TablesArrangement.all.order(discomfort: :asc).limit(3).as_json(only: %i[id name discomfort]) render json: TablesArrangement.order(discomfort: :asc).limit(3).as_json(only: %i[id name discomfort])
end end
def show def show

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class TokensController < ApplicationController class TokensController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_before_action :set_tenant skip_before_action :set_tenant

View File

@ -1,23 +1,27 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
class Users::ConfirmationsController < Devise::ConfirmationsController # frozen_string_literal: true
clear_respond_to
respond_to :json
def show module Users
super do |resource| class ConfirmationsController < Devise::ConfirmationsController
if resource.errors.empty? clear_respond_to
respond_to do |format| respond_to :json
format.json { render json: resource, status: :ok }
format.any { redirect_to root_path } def show
super do |resource|
if resource.errors.empty?
respond_to do |format|
format.json { render json: resource, status: :ok }
format.any { redirect_to root_path }
end
else
render json: {
message: 'Record invalid',
errors: resource.errors.full_messages
}, status: :unprocessable_entity
end end
else return
render json: {
message: 'Record invalid',
errors: resource.errors.full_messages
}, status: :unprocessable_entity
end end
return
end end
end end
end end

View File

@ -1,28 +1,32 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
class Users::RegistrationsController < Devise::RegistrationsController # frozen_string_literal: true
clear_respond_to
respond_to :json
before_action :validate_captcha!, only: :create module Users
class RegistrationsController < Devise::RegistrationsController
clear_respond_to
respond_to :json
def create before_action :validate_captcha!, only: :create
wedding = Wedding.create(slug: params[:slug])
unless wedding.persisted?
render json: { errors: wedding.errors.full_messages }, status: :unprocessable_entity
return
end
ActsAsTenant.with_tenant(wedding) do def create
super do |user| wedding = Wedding.create(slug: params[:slug])
wedding.destroy unless user.persisted? unless wedding.persisted?
render json: { errors: wedding.errors.full_messages }, status: :unprocessable_entity
return
end
ActsAsTenant.with_tenant(wedding) do
super do |user|
wedding.destroy unless user.persisted?
end
end end
end end
end
private private
def set_tenant def set_tenant
set_current_tenant(nil) set_current_tenant(nil)
end
end end
end end

View File

@ -1,6 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
class Users::SessionsController < Devise::SessionsController # frozen_string_literal: true
clear_respond_to
respond_to :json module Users
class SessionsController < Devise::SessionsController
clear_respond_to
respond_to :json
end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module TreeNodeExtension module TreeNodeExtension
def distance_to_common_ancestor(another_node) def distance_to_common_ancestor(another_node)
return 0 if self == another_node return 0 if self == another_node

View File

@ -1,4 +1,6 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module ApplicationHelper module ApplicationHelper
end end

View File

@ -1,4 +1,6 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module ExpensesHelper module ExpensesHelper
end end

View File

@ -1,4 +1,6 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module GroupsHelper module GroupsHelper
end end

View File

@ -1,4 +1,6 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module GuestsHelper module GuestsHelper
end end

View File

@ -1,4 +1,6 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module TablesArrangementsHelper module TablesArrangementsHelper
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class ApplicationJob < ActiveJob::Base class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock # Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked # retry_on ActiveRecord::Deadlocked

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class TableSimulatorJob < ApplicationJob class TableSimulatorJob < ApplicationJob
queue_as :default queue_as :default

View File

@ -1,6 +1,8 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: "from@example.com" default from: 'from@example.com'
layout "mailer" layout 'mailer'
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
primary_abstract_class primary_abstract_class
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: expenses # Table name: expenses

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: groups # Table name: groups
@ -31,7 +33,7 @@ class Group < ApplicationRecord
validates :name, uniqueness: true validates :name, uniqueness: true
validates :name, :order, presence: true validates :name, :order, presence: true
has_many :children, class_name: 'Group', foreign_key: 'parent_id' has_many :children, class_name: 'Group', foreign_key: 'parent_id', dependent: :nullify, inverse_of: :parent
belongs_to :parent, class_name: 'Group', optional: true belongs_to :parent, class_name: 'Group', optional: true
before_create :set_color before_create :set_color
@ -41,10 +43,7 @@ class Group < ApplicationRecord
has_many :guests, dependent: :nullify has_many :guests, dependent: :nullify
def colorize_children(generation = 1) def colorize_children(generation = 1)
derived_colors = generation == 1 ? color.paint.palette.analogous(size: children.count) : color.paint.palette.decreasing_saturation children.zip(palette(generation)) do |child, raw_color|
children.zip(derived_colors) do |child, raw_color|
final_color = raw_color.paint final_color = raw_color.paint
final_color.brighten(60) if final_color.dark? final_color.brighten(60) if final_color.dark?
@ -54,8 +53,20 @@ class Group < ApplicationRecord
end end
end end
def affinities
GroupAffinity.where(group_a_id: id).or(GroupAffinity.where(group_b_id: id))
end
private private
def palette(generation)
if generation == 1
color.paint.palette.analogous(size: children.count)
else
color.paint.palette.decreasing_saturation
end
end
def set_color def set_color
return if color.present? return if color.present?

View File

@ -0,0 +1,43 @@
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information
#
# Table name: group_affinities
#
# id :bigint not null, primary key
# discomfort :float not null
# created_at :datetime not null
# updated_at :datetime not null
# group_a_id :uuid not null
# group_b_id :uuid not null
#
# Indexes
#
# index_group_affinities_on_group_a_id (group_a_id)
# index_group_affinities_on_group_b_id (group_b_id)
# uindex_group_pair (LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (group_a_id => groups.id)
# fk_rails_... (group_b_id => groups.id)
#
class GroupAffinity < ApplicationRecord
NEUTRAL = 1
MIN_DISCOMFORT = 0
MAX_DISCOMFORT = 2
belongs_to :group_a, class_name: 'Group'
belongs_to :group_b, class_name: 'Group'
validates :discomfort,
numericality: { greater_than_or_equal_to: MIN_DISCOMFORT, less_than_or_equal_to: MAX_DISCOMFORT }
def another_group(group)
return nil if group != group_a && group != group_b
group == group_a ? group_b : group_a
end
end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: guests # Table name: guests
@ -39,8 +41,8 @@ class Guest < ApplicationRecord
scope :potential, -> { where.not(status: %i[declined considered]) } scope :potential, -> { where.not(status: %i[declined considered]) }
after_save :recalculate_simulations, if: :saved_change_to_status?
after_destroy :recalculate_simulations after_destroy :recalculate_simulations
after_save :recalculate_simulations, if: :saved_change_to_status?
has_many :seats, dependent: :delete_all has_many :seats, dependent: :delete_all

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: seats # Table name: seats

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: tables_arrangements # Table name: tables_arrangements
@ -21,7 +23,7 @@
# #
class TablesArrangement < ApplicationRecord class TablesArrangement < ApplicationRecord
acts_as_tenant :wedding acts_as_tenant :wedding
has_many :seats has_many :seats, dependent: :delete_all
has_many :guests, through: :seats has_many :guests, through: :seats
before_create :assign_name before_create :assign_name

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: users # Table name: users

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# == Schema Information # == Schema Information
# #
# Table name: weddings # Table name: weddings

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Expenses module Expenses
class TotalQuery class TotalQuery
private attr_reader :wedding private attr_reader :wedding
@ -16,7 +18,7 @@ module Expenses
private private
def query def query
<<~SQL <<~SQL.squish
WITH guest_count AS (#{guest_count_per_status}), WITH guest_count AS (#{guest_count_per_status}),
expense_summary AS (#{expense_summary}) expense_summary AS (#{expense_summary})
SELECT guest_count.confirmed as confirmed_guests, SELECT guest_count.confirmed as confirmed_guests,
@ -28,7 +30,7 @@ module Expenses
end end
def expense_summary def expense_summary
<<~SQL <<~SQL.squish
SELECT coalesce(sum(amount) filter (where pricing_type = 'fixed'), 0) as fixed, SELECT coalesce(sum(amount) filter (where pricing_type = 'fixed'), 0) as fixed,
coalesce(sum(amount) filter (where pricing_type = 'per_person'), 0) as variable coalesce(sum(amount) filter (where pricing_type = 'per_person'), 0) as variable
FROM expenses FROM expenses
@ -37,7 +39,7 @@ module Expenses
end end
def guest_count_per_status def guest_count_per_status
<<~SQL <<~SQL.squish
SELECT COALESCE(count(*) filter(where status = #{Guest.statuses['confirmed']}), 0) as confirmed, SELECT COALESCE(count(*) filter(where status = #{Guest.statuses['confirmed']}), 0) as confirmed,
COALESCE(count(*) filter(where status IN (#{Guest.statuses.values_at('confirmed', 'invited', 'tentative').join(',')})), 0) as projected COALESCE(count(*) filter(where status IN (#{Guest.statuses.values_at('confirmed', 'invited', 'tentative').join(',')})), 0) as projected
FROM guests FROM guests

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Groups module Groups
class SummaryQuery class SummaryQuery
def call def call
@ -9,13 +11,21 @@ module Groups
:icon, :icon,
:parent_id, :parent_id,
:color, :color,
*count_expressions
)
end
private
def count_expressions
[
Arel.sql('count(*) filter (where status IS NOT NULL) as total'), Arel.sql('count(*) filter (where status IS NOT NULL) as total'),
Arel.sql('count(*) filter (where status = 0) as considered'), Arel.sql('count(*) filter (where status = 0) as considered'),
Arel.sql('count(*) filter (where status = 10) as invited'), Arel.sql('count(*) filter (where status = 10) as invited'),
Arel.sql('count(*) filter (where status = 20) as confirmed'), Arel.sql('count(*) filter (where status = 20) as confirmed'),
Arel.sql('count(*) filter (where status = 30) as declined'), Arel.sql('count(*) filter (where status = 30) as declined'),
Arel.sql('count(*) filter (where status = 40) as tentative'), Arel.sql('count(*) filter (where status = 40) as tentative')
) ]
end end
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class SerializableGroup < JSONAPI::Serializable::Resource class SerializableGroup < JSONAPI::Serializable::Resource
type 'group' type 'group'

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class SerializableGuest < JSONAPI::Serializable::Resource class SerializableGuest < JSONAPI::Serializable::Resource
type 'guest' type 'guest'

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class AffinityGroupsHierarchy < Array class AffinityGroupsHierarchy < Array
include Singleton include Singleton

View File

@ -1,20 +1,20 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
class LibreCaptcha class LibreCaptcha
def get_id def id
HTTParty.post("http://libre-captcha:8888/v2/captcha", HTTParty.post('http://libre-captcha:8888/v2/captcha',
body: { body: {
input_type: "text", input_type: 'text',
level: :hard, level: :hard,
media: 'image/png', media: 'image/png',
size: '350x100' size: '350x100'
}.to_json }.to_json).then { |raw| JSON.parse(raw)['id'] }
).then { |raw| JSON.parse(raw)['id'] }
end end
def valid?(id:, answer:) def valid?(id:, answer:)
HTTParty.post("http://libre-captcha:8888/v2/answer", HTTParty.post('http://libre-captcha:8888/v2/answer',
body: { id:, answer: }.to_json body: { id:, answer: }.to_json).then { |raw| JSON.parse(raw)['result'] == 'True' }
).then { |raw| JSON.parse(raw)['result'] == 'True' }
end end
end end

View File

@ -1,7 +1,19 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Tables module Tables
class DiscomfortCalculator class DiscomfortCalculator
class << self
def cohesion_discomfort(id_a:, id_b:)
distance = AffinityGroupsHierarchy.instance.distance(id_a, id_b)
return 1 if distance.nil?
Rational(distance, distance + 1)
end
end
private attr_reader :table private attr_reader :table
def initialize(table:) def initialize(table:)
@table = table @table = table
@ -45,12 +57,7 @@ module Tables
# #
def cohesion_discomfort def cohesion_discomfort
table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)| table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)|
distance = AffinityGroupsHierarchy.instance.distance(a, b) count_a * count_b * self.class.cohesion_discomfort(id_a: a, id_b: b)
next count_a * count_b if distance.nil?
next 0 if distance.zero?
count_a * count_b * Rational(distance, distance + 1)
end end
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require_relative '../../extensions/tree_node_extension' require_relative '../../extensions/tree_node_extension'
module Tables module Tables

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Tables module Tables
class Shift class Shift
private attr_reader :initial_solution private attr_reader :initial_solution

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Tables module Tables
class Swap class Swap
private attr_reader :initial_solution private attr_reader :initial_solution

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Tables module Tables
class Table < Set class Table < Set
attr_accessor :discomfort, :min_per_table, :max_per_table attr_accessor :discomfort, :min_per_table, :max_per_table

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module VNS module VNS
class Engine class Engine
class << self class << self

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
# This file is used by Rack-based servers to start the application. # This file is used by Rack-based servers to start the application.
require_relative "config/environment" require_relative 'config/environment'
run Rails.application run Rails.application
Rails.application.load_server Rails.application.load_server

View File

@ -23,7 +23,12 @@ Rails.application.routes.draw do
get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation
end end
resources :groups, only: %i[index create update destroy] resources :groups, only: %i[index create update destroy] do
resources :affinities, only: %i[index] do
put :bulk_update, on: :collection
end
end
resources :guests, only: %i[index create update destroy] do resources :guests, only: %i[index create update destroy] do
post :bulk_update, on: :collection post :bulk_update, on: :collection
end end

View File

@ -0,0 +1,29 @@
# Copyright (C) 2024 Manuel Bustillo
class CreateGroupAffinities < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
create_table :group_affinities, if_not_exists: true do |t|
t.references :group_a, type: :uuid, null: false, foreign_key: { to_table: :groups }
t.references :group_b, type: :uuid, null: false, foreign_key: { to_table: :groups }
t.float :discomfort, null: false
t.timestamps
end
add_check_constraint :group_affinities, 'group_a_id != group_b_id', name: :check_distinct_groups, if_not_exists: true
add_check_constraint :group_affinities, 'discomfort >= 0 AND discomfort <= 2', if_not_exists: true
reversible do |dir|
dir.up do
execute <<~SQL
CREATE UNIQUE INDEX CONCURRENTLY uindex_group_pair ON group_affinities (least(group_a_id, group_b_id), greatest(group_a_id, group_b_id));
SQL
end
dir.down do
remove_index :group_affinities, name: :uindex_group_pair, if_exists: true
end
end
end
end

17
db/schema.rb generated
View File

@ -12,7 +12,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do ActiveRecord::Schema[8.0].define(version: 2024_12_16_231415) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -30,6 +30,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do
t.index ["wedding_id"], name: "index_expenses_on_wedding_id" t.index ["wedding_id"], name: "index_expenses_on_wedding_id"
end end
create_table "group_affinities", force: :cascade do |t|
t.uuid "group_a_id", null: false
t.uuid "group_b_id", null: false
t.float "discomfort", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index "LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)", name: "uindex_group_pair", unique: true
t.index ["group_a_id"], name: "index_group_affinities_on_group_a_id"
t.index ["group_b_id"], name: "index_group_affinities_on_group_b_id"
t.check_constraint "discomfort >= 0::double precision AND discomfort <= 2::double precision"
t.check_constraint "group_a_id <> group_b_id", name: "check_distinct_groups"
end
create_table "groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "icon" t.string "icon"
@ -228,6 +241,8 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_08_102932) do
end end
add_foreign_key "expenses", "weddings" add_foreign_key "expenses", "weddings"
add_foreign_key "group_affinities", "groups", column: "group_a_id"
add_foreign_key "group_affinities", "groups", column: "group_b_id"
add_foreign_key "groups", "groups", column: "parent_id" add_foreign_key "groups", "groups", column: "parent_id"
add_foreign_key "groups", "weddings" add_foreign_key "groups", "weddings"
add_foreign_key "guests", "groups" add_foreign_key "guests", "groups"

View File

@ -11,6 +11,13 @@ services:
environment: environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
RAILS_ENV: development RAILS_ENV: development
tty: true
stdin_open: true
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
- .:/rails - .:/rails
workers: workers:
@ -32,6 +39,11 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
ports: ports:
- 3000 - 3000
healthcheck:
test: wget -qO - http://localhost:3000/api/health || exit 1
interval: 10s
timeout: 5s
retries: 5
depends_on: depends_on:
- backend - backend
volumes: volumes:
@ -50,8 +62,10 @@ services:
volumes: volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: depends_on:
- frontend frontend:
- backend condition: service_healthy
backend:
condition: service_healthy
db: db:
image: postgres:17 image: postgres:17
ports: ports:

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
# This rake task was added by annotate_rb gem. # This rake task was added by annotate_rb gem.
# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this # Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this
if Rails.env.development? && ENV["ANNOTATERB_SKIP_ON_DB_TASKS"].nil? if Rails.env.development? && ENV['ANNOTATERB_SKIP_ON_DB_TASKS'].nil?
require "annotate_rb" require 'annotate_rb'
AnnotateRb::Core.load_rake_tasks AnnotateRb::Core.load_rake_tasks
end end

View File

@ -1,70 +1,72 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Tree module Tree
RSpec.describe TreeNode do RSpec.describe TreeNode do
describe '#distance_to_common_ancestor' do describe '#distance_to_common_ancestor' do
def assert_distance(node_1, node_2, distance) def assert_distance(node1, node2, distance)
aggregate_failures do aggregate_failures do
expect(node_1.distance_to_common_ancestor(node_2)).to eq(distance) expect(node1.distance_to_common_ancestor(node2)).to eq(distance)
expect(node_2.distance_to_common_ancestor(node_1)).to eq(distance) expect(node2.distance_to_common_ancestor(node1)).to eq(distance)
end end
end end
context 'when the two nodes are the same' do context 'when the two nodes are the same' do
it 'returns 0 when comparing the root itself' do it 'returns 0 when comparing the root itself' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
assert_distance(root, root, 0) assert_distance(root, root, 0)
end end
it 'returns 0 when comparing a child to itself' do it 'returns 0 when comparing a child to itself' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
child = root << Tree::TreeNode.new('child') child = root << described_class.new('child')
assert_distance(child, child, 0) assert_distance(child, child, 0)
end end
end end
context 'when the two nodes are siblings' do context 'when the two nodes are siblings' do
it 'returns 1 when comparing siblings' do it 'returns 1 when comparing siblings' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
child1 = root << Tree::TreeNode.new('child1') child1 = root << described_class.new('child1')
child2 = root << Tree::TreeNode.new('child2') child2 = root << described_class.new('child2')
assert_distance(child1, child2, 1) assert_distance(child1, child2, 1)
end end
end end
context 'when one node is parent of the other' do context 'when one node is parent of the other' do
it 'returns 1 when comparing parent to child' do it 'returns 1 when comparing parent to child' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
child = root << Tree::TreeNode.new('child') child = root << described_class.new('child')
assert_distance(root, child, 1) assert_distance(root, child, 1)
end end
end end
context 'when one node is grandparent of the other' do context 'when one node is grandparent of the other' do
it 'returns 2 when comparing grandparent to grandchild' do it 'returns 2 when comparing grandparent to grandchild' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
child = root << Tree::TreeNode.new('child') child = root << described_class.new('child')
grandchild = child << Tree::TreeNode.new('grandchild') grandchild = child << described_class.new('grandchild')
assert_distance(root, grandchild, 2) assert_distance(root, grandchild, 2)
end end
end end
context 'when the two nodes are cousins' do context 'when the two nodes are cousins' do
it 'returns 2 when comparing cousins' do it 'returns 2 when comparing cousins' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
child1 = root << Tree::TreeNode.new('child1') child1 = root << described_class.new('child1')
child2 = root << Tree::TreeNode.new('child2') child2 = root << described_class.new('child2')
grandchild1 = child1 << Tree::TreeNode.new('grandchild1') grandchild1 = child1 << described_class.new('grandchild1')
grandchild2 = child2 << Tree::TreeNode.new('grandchild2') grandchild2 = child2 << described_class.new('grandchild2')
assert_distance(grandchild1, grandchild2, 2) assert_distance(grandchild1, grandchild2, 2)
end end
end end
context 'when the two nodes are not related' do context 'when the two nodes are not related' do
it 'returns nil' do it 'returns nil' do
root = Tree::TreeNode.new('root') root = described_class.new('root')
another_root = Tree::TreeNode.new('another_root') another_root = described_class.new('another_root')
assert_distance(root, another_root, nil) assert_distance(root, another_root, nil)
end end
end end

View File

@ -1,19 +1,22 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :expense do factory :expense do
wedding wedding
sequence(:name) { |i| "Expense #{i}" } sequence(:name) { |i| "Expense #{i}" }
pricing_type { "fixed" } pricing_type { 'fixed' }
amount { 100 } amount { 100 }
end
trait :fixed do
pricing_type { "fixed" }
end
trait :per_person do
pricing_type { "per_person" }
end
end end
trait :fixed do
pricing_type { 'fixed' }
end
trait :per_person do
pricing_type { 'per_person' }
end
end

View File

@ -0,0 +1,13 @@
# Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do
factory :group_affinity do
group_a factory: %i[group]
group_b factory: %i[group]
discomfort { GroupAffinity::NEUTRAL }
end
end

View File

@ -1,5 +1,9 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :group do factory :group do
wedding wedding

View File

@ -1,5 +1,9 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :guest do factory :guest do
group group

View File

@ -1,8 +1,11 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :tables_arrangement do factory :tables_arrangement do
wedding wedding
end end
end end

View File

@ -1,5 +1,9 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
wedding wedding

View File

@ -1,5 +1,9 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :wedding do factory :wedding do
sequence(:slug) { |i| "wedding-#{i}" } sequence(:slug) { |i| "wedding-#{i}" }

View File

@ -1,12 +1,16 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Expense, type: :model do RSpec.describe Expense do
describe 'validations' do describe 'validations' do
it { should validate_presence_of(:name) } it { is_expected.to validate_presence_of(:name) }
it { should validate_presence_of(:amount) } it { is_expected.to validate_presence_of(:amount) }
it { should validate_numericality_of(:amount).is_greater_than(0) } it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) }
it { should validate_presence_of(:pricing_type) } it { is_expected.to validate_presence_of(:pricing_type) }
end end
end end

View File

@ -0,0 +1,46 @@
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe GroupAffinity do
subject(:affinity) { build(:group_affinity, group_a:, group_b:) }
let(:wedding) { create(:wedding) }
let(:group_a) { create(:group, wedding:) }
let(:group_b) { create(:group, wedding:) }
let(:group_c) { create(:group, wedding:) }
describe 'validations' do
it do
expect(affinity).to validate_numericality_of(:discomfort)
.is_greater_than_or_equal_to(0)
.is_less_than_or_equal_to(2)
end
end
describe '.create' do
before do
create(:group_affinity, group_a: group_a, group_b: group_b)
end
it 'disallows the creation of a group affinity with the same group on both sides' do
expect do
create(:group_affinity, group_a: group_c, group_b: group_c)
end.to raise_error(ActiveRecord::StatementInvalid)
end
it 'disallows the creation of a group affinity that already exists' do
expect do
create(:group_affinity, group_a: group_a, group_b: group_b)
end.to raise_error(ActiveRecord::StatementInvalid)
end
it 'disallows the creation of a group affinity with the same groups in reverse order' do
expect do
create(:group_affinity, group_a: group_b, group_b: group_a)
end.to raise_error(ActiveRecord::StatementInvalid)
end
end
end

View File

@ -1,10 +1,14 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Group, type: :model do RSpec.describe Group do
describe 'callbacks' do describe 'callbacks' do
it 'should set color before create' do it 'sets color before create' do
expect(create(:group).color).to be_present expect(create(:group).color).to be_present
end end
end end

View File

@ -1,12 +1,17 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Guest, type: :model do RSpec.describe Guest do
describe 'validations' do describe 'validations' do
it { should validate_presence_of(:name) } subject(:guest) { build(:guest) }
it { is_expected.to validate_presence_of(:name) }
it do it do
should define_enum_for(:status).with_values( expect(guest).to define_enum_for(:status).with_values(
considered: 0, considered: 0,
invited: 10, invited: 10,
confirmed: 20, confirmed: 20,
@ -16,7 +21,7 @@ RSpec.describe Guest, type: :model do
end end
end end
it { should belong_to(:group).optional } it { is_expected.to belong_to(:group).optional }
describe 'scopes' do describe 'scopes' do
describe '.potential' do describe '.potential' do
@ -27,7 +32,7 @@ RSpec.describe Guest, type: :model do
confirmed_guest = create(:guest, status: :confirmed) confirmed_guest = create(:guest, status: :confirmed)
tentative_guest = create(:guest, status: :tentative) tentative_guest = create(:guest, status: :tentative)
expect(Guest.potential).to match_array([invited_guest, confirmed_guest, tentative_guest]) expect(described_class.potential).to contain_exactly(invited_guest, confirmed_guest, tentative_guest)
end end
end end
end end

View File

@ -1,7 +1,11 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Seat, type: :model do RSpec.describe Seat do
pending "add some examples to (or delete) #{__FILE__}" pending "add some examples to (or delete) #{__FILE__}"
end end

View File

@ -1,8 +1,12 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe TablesArrangement, type: :model do RSpec.describe TablesArrangement do
describe 'callbacks' do describe 'callbacks' do
it 'assigns a name before creation' do it 'assigns a name before creation' do
expect(create(:tables_arrangement).name).to be_present expect(create(:tables_arrangement).name).to be_present

View File

@ -1,7 +1,11 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe User, type: :model do RSpec.describe User do
pending "add some examples to (or delete) #{__FILE__}" pending "add some examples to (or delete) #{__FILE__}"
end end

View File

@ -1,22 +1,27 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Wedding, type: :model do RSpec.describe Wedding do
describe 'validations' do describe 'validations' do
subject { build(:wedding) } subject { build(:wedding) }
describe 'slug' do
it { should allow_value('foo').for(:slug) }
it { should allow_value('foo-bar').for(:slug) }
it { should allow_value('foo-123').for(:slug) }
it { should allow_value('foo-123-').for(:slug) }
it { should allow_value('foo--123').for(:slug) }
it { should_not allow_value('Foo').for(:slug) } describe 'slug' do
it { should_not allow_value('/foo').for(:slug) } it { is_expected.to allow_value('foo').for(:slug) }
it { should_not allow_value('foo/123').for(:slug) } it { is_expected.to allow_value('foo-bar').for(:slug) }
it { should_not allow_value('foo_123').for(:slug) } it { is_expected.to allow_value('foo-123').for(:slug) }
it { should_not allow_value('foo/').for(:slug) } it { is_expected.to allow_value('foo-123-').for(:slug) }
it { is_expected.to allow_value('foo--123').for(:slug) }
it { is_expected.not_to allow_value('Foo').for(:slug) }
it { is_expected.not_to allow_value('/foo').for(:slug) }
it { is_expected.not_to allow_value('foo/123').for(:slug) }
it { is_expected.not_to allow_value('foo_123').for(:slug) }
it { is_expected.not_to allow_value('foo/').for(:slug) }
end end
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Expenses module Expenses
@ -61,8 +63,8 @@ module Expenses
end end
it 'returns the sum of fixed and variable expenses', :aggregate_failures do it 'returns the sum of fixed and variable expenses', :aggregate_failures do
expect(response['total_confirmed']).to eq(100 + 200 + 50 * 2) expect(response['total_confirmed']).to eq(100 + 200 + (50 * 2))
expect(response['total_projected']).to eq(100 + 200 + 11 * 50) expect(response['total_projected']).to eq(100 + 200 + (11 * 50))
expect(response['confirmed_guests']).to eq(2) expect(response['confirmed_guests']).to eq(2)
expect(response['projected_guests']).to eq(2 + 4 + 5) expect(response['projected_guests']).to eq(2 + 4 + 5)
end end

View File

@ -1,11 +1,13 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Groups module Groups
RSpec.describe SummaryQuery do RSpec.describe SummaryQuery do
describe '#call' do describe '#call' do
subject { described_class.new.call } subject(:result) { described_class.new.call }
context 'when there are no groups' do context 'when there are no groups' do
it { is_expected.to eq([]) } it { is_expected.to eq([]) }
@ -17,7 +19,7 @@ module Groups
context 'when there are no guests' do context 'when there are no guests' do
it 'returns the summary of groups' do it 'returns the summary of groups' do
is_expected.to contain_exactly( expect(result).to contain_exactly(
{ 'id' => parent.id, { 'id' => parent.id,
'name' => 'Friends', 'name' => 'Friends',
'icon' => 'icon-1', 'icon' => 'icon-1',
@ -58,11 +60,11 @@ module Groups
create_list(:guest, 8, group: child, status: :invited) create_list(:guest, 8, group: child, status: :invited)
create_list(:guest, 9, group: child, status: :confirmed) create_list(:guest, 9, group: child, status: :confirmed)
create_list(:guest, 10, group: child, status: :declined) create_list(:guest, 10, group: child, status: :declined)
create_list(:guest, 11, group: child, status: :tentative) create_list(:guest, 11, group: child, status: :tentative) # rubocop:disable FactoryBot/ExcessiveCreateList
end end
it 'returns the summary of groups' do it 'returns the summary of groups' do
is_expected.to contain_exactly( expect(result).to contain_exactly(
{ {
'id' => parent.id, 'id' => parent.id,
'name' => 'Friends', 'name' => 'Friends',

View File

@ -1,11 +1,13 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# This file is copied to spec/ when you run 'rails generate rspec:install' # This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper' require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test' ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment' require_relative '../config/environment'
# Prevent database truncation if the environment is production # Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production? abort('The Rails environment is running in production mode!') if Rails.env.production?
require 'rspec/rails' require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point! # Add additional requires below this line. Rails is not loaded until this point!

View File

@ -0,0 +1,52 @@
# Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'affinities' do
path '/{slug}/groups/{group_id}/affinities' do
parameter Swagger::Schema::SLUG
parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id'
get('list affinities') do
tags 'Affinities'
produces 'application/json'
response(200, 'successful') do
schema type: :object, additionalProperties: { type: :integer, minimum: 0, maximum: 2 }
xit
end
end
end
path '/{slug}/groups/{group_id}/affinities/bulk_update' do
parameter Swagger::Schema::SLUG
parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id'
put('bulk update affinities') do
tags 'Affinities'
produces 'application/json'
consumes 'application/json'
parameter name: :body, in: :body, schema: {
type: :object,
required: [:affinities],
properties: {
affinities: {
type: :array,
items: {
type: :object,
required: %i[group_id affinity],
properties: {
group_id: { type: :string, format: :uuid, description: 'ID of the associated group' },
affinity: { type: :integer, minimum: 0, maximum: 2 }
}
}
}
}
}
response_empty200
end
end
end

View File

@ -1,10 +1,11 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'captcha', type: :request do RSpec.describe 'captcha' do
path '/captcha' do path '/captcha' do
post('create a CAPTCHA challenge') do post('create a CAPTCHA challenge') do
tags 'CAPTCHA' tags 'CAPTCHA'
consumes 'application/json' consumes 'application/json'
@ -12,11 +13,11 @@ RSpec.describe 'captcha', type: :request do
response(201, 'created') do response(201, 'created') do
schema type: :object, schema type: :object,
required: %i[id], required: %i[id],
properties: { properties: {
id: { type: :string, format: :uuid }, id: { type: :string, format: :uuid },
media_url: { type: :string, format: :uri }, media_url: { type: :string, format: :uri }
} }
xit xit
end end
end end

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'expenses', type: :request do RSpec.describe 'expenses' do
path '/{slug}/expenses' do path '/{slug}/expenses' do
get('list expenses') do get('list expenses') do
tags 'Expenses' tags 'Expenses'
@ -42,8 +44,8 @@ RSpec.describe 'expenses', type: :request do
} }
} }
response_empty_201 response_empty201
response_422 response422
regular_api_responses regular_api_responses
end end
end end
@ -60,9 +62,9 @@ RSpec.describe 'expenses', type: :request do
properties: Swagger::Schema::EXPENSE properties: Swagger::Schema::EXPENSE
} }
response_empty_200 response_empty200
response_422 response422
response_404 response404
regular_api_responses regular_api_responses
end end
@ -71,8 +73,8 @@ RSpec.describe 'expenses', type: :request do
produces 'application/json' produces 'application/json'
parameter Swagger::Schema::SLUG parameter Swagger::Schema::SLUG
parameter Swagger::Schema::ID parameter Swagger::Schema::ID
response_empty_200 response_empty200
response_404 response404
regular_api_responses regular_api_responses
end end
end end

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'groups', type: :request do RSpec.describe 'groups' do
path '/{slug}/groups' do path '/{slug}/groups' do
get('list groups') do get('list groups') do
tags 'Groups' tags 'Groups'
@ -10,29 +12,29 @@ RSpec.describe 'groups', type: :request do
parameter Swagger::Schema::SLUG parameter Swagger::Schema::SLUG
response(200, 'successful') do response(200, 'successful') do
schema type: :array, schema type: :array,
items: { items: {
type: :object, type: :object,
required: %i[id name icon parent_id color attendance], required: %i[id name icon parent_id color attendance],
properties: { properties: {
id: { type: :string, format: :uuid, required: true }, id: { type: :string, format: :uuid, required: true },
name: { type: :string }, name: { type: :string },
icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' }, icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' },
parent_id: { type: :string, format: :uuid }, parent_id: { type: :string, format: :uuid },
color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' }, color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' },
attendance: { attendance: {
type: :object, type: :object,
required: %i[total considered invited confirmed declined tentative], required: %i[total considered invited confirmed declined tentative],
properties: { properties: {
total: { type: :integer, minimum: 0, description: 'Total number of guests in any status' }, total: { type: :integer, minimum: 0, description: 'Total number of guests in any status' },
considered: { type: :integer, minimum: 0 }, considered: { type: :integer, minimum: 0 },
invited: { type: :integer, minimum: 0 }, invited: { type: :integer, minimum: 0 },
confirmed: { type: :integer, minimum: 0 }, confirmed: { type: :integer, minimum: 0 },
declined: { type: :integer, minimum: 0 }, declined: { type: :integer, minimum: 0 },
tentative: { type: :integer, minimum: 0 } tentative: { type: :integer, minimum: 0 }
} }
} }
}
} }
}
xit xit
end end
regular_api_responses regular_api_responses
@ -100,7 +102,7 @@ RSpec.describe 'groups', type: :request do
parameter Swagger::Schema::SLUG parameter Swagger::Schema::SLUG
parameter name: :id, in: :path, type: :string, format: :uuid parameter name: :id, in: :path, type: :string, format: :uuid
response_empty_200 response_empty200
regular_api_responses regular_api_responses
end end
end end

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'guests', type: :request do RSpec.describe 'guests' do
path '/{slug}/guests' do path '/{slug}/guests' do
get('list guests') do get('list guests') do
tags 'Guests' tags 'Guests'
@ -51,8 +53,8 @@ RSpec.describe 'guests', type: :request do
} }
} }
response_empty_201 response_empty201
response_422 response422
regular_api_responses regular_api_responses
end end
end end
@ -80,9 +82,9 @@ RSpec.describe 'guests', type: :request do
} }
} }
response_empty_200 response_empty200
response_422 response422
response_404 response404
regular_api_responses regular_api_responses
end end
@ -92,8 +94,8 @@ RSpec.describe 'guests', type: :request do
parameter Swagger::Schema::SLUG parameter Swagger::Schema::SLUG
parameter name: 'id', in: :path, type: :string, format: :uuid parameter name: 'id', in: :path, type: :string, format: :uuid
response_empty_200 response_empty200
response_404 response404
regular_api_responses regular_api_responses
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module Swagger module Swagger
module Schema module Schema
USER = { USER = {
@ -7,13 +9,13 @@ module Swagger
email: { type: :string, format: :email }, email: { type: :string, format: :email },
created_at: SwaggerResponseHelper::TIMESTAMP, created_at: SwaggerResponseHelper::TIMESTAMP,
updated_at: SwaggerResponseHelper::TIMESTAMP updated_at: SwaggerResponseHelper::TIMESTAMP
} }.freeze
ID = { ID = { # rubocop:disable Style/MutableConstant -- rswag modifies in: :path parameters
name: 'id', name: 'id',
in: :path, in: :path,
type: :string, type: :string,
format: :uuid, format: :uuid
} }
GROUP = { GROUP = {
@ -21,15 +23,15 @@ module Swagger
icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' }, icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' },
parent_id: { type: :string, format: :uuid }, parent_id: { type: :string, format: :uuid },
color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' } color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' }
} }.freeze
EXPENSE = { EXPENSE = {
name: { type: :string }, name: { type: :string },
amount: { type: :number, minimum: 0 }, amount: { type: :number, minimum: 0 },
pricing_type: { type: :string, enum: Expense.pricing_types.keys } pricing_type: { type: :string, enum: Expense.pricing_types.keys }
} }.freeze
SLUG = { SLUG = { # rubocop:disable Style/MutableConstant -- rswag modifies in: :path parameters
name: 'slug', name: 'slug',
in: :path, in: :path,
type: :string, type: :string,
@ -47,6 +49,6 @@ module Swagger
answer: { type: :string } answer: { type: :string }
} }
} }
} }.freeze
end end
end end

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'summary', type: :request do RSpec.describe 'summary' do
path '/{slug}/summary' do path '/{slug}/summary' do
get('list summaries') do get('list summaries') do
tags 'Summary' tags 'Summary'

View File

@ -1,8 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'tables_arrangements', type: :request do RSpec.describe 'tables_arrangements' do
path '/{slug}/tables_arrangements' do path '/{slug}/tables_arrangements' do
get('list tables arrangements') do get('list tables arrangements') do
tags 'Tables Arrangements' tags 'Tables Arrangements'

View File

@ -1,15 +1,5 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'tokens', type: :request do
path '/token' do
get('get a cookie with CSRF token') do
tags 'CSRF token'
consumes 'application/json'
produces 'application/json'
response_empty_200
end
end
end

View File

@ -1,9 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'users/confirmations', type: :request do RSpec.describe 'users/confirmations' do
path '/{slug}/users/confirmation' do path '/{slug}/users/confirmation' do
get('confirm user email') do get('confirm user email') do
tags 'Users' tags 'Users'
@ -17,7 +18,7 @@ RSpec.describe 'users/confirmations', type: :request do
xit xit
end end
response_422 response422
end end
end end
end end

View File

@ -1,9 +1,10 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'users/registrations', type: :request do RSpec.describe 'users/registrations' do
path '/{slug}/users' do path '/{slug}/users' do
post('create registration') do post('create registration') do
tags 'Users Registrations' tags 'Users Registrations'
@ -13,13 +14,13 @@ RSpec.describe 'users/registrations', type: :request do
parameter Swagger::Schema::SLUG parameter Swagger::Schema::SLUG
parameter name: :body, in: :body, schema: { parameter name: :body, in: :body, schema: {
type: :object, type: :object,
required: [:user, :wedding], required: %i[user wedding],
properties: { properties: {
user: { user: {
type: :object, type: :object,
required: %i[email password password_confirmation], required: %i[email password password_confirmation],
properties: { properties: {
email: { type: :string, format: :email}, email: { type: :string, format: :email },
password: SwaggerResponseHelper::PASSWORD, password: SwaggerResponseHelper::PASSWORD,
password_confirmation: SwaggerResponseHelper::PASSWORD password_confirmation: SwaggerResponseHelper::PASSWORD
} }

View File

@ -1,11 +1,11 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'users/sessions', type: :request do RSpec.describe 'users/sessions' do
path '/{slug}/users/sign_in' do path '/{slug}/users/sign_in' do
post('create session') do post('create session') do
tags 'Users Sessions' tags 'Users Sessions'
consumes 'application/json' consumes 'application/json'
@ -32,7 +32,7 @@ RSpec.describe 'users/sessions', type: :request do
xit xit
end end
response_401(message: 'Invalid Email or password.') response401(message: 'Invalid Email or password.')
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Tables module Tables
RSpec.describe DiscomfortCalculator do RSpec.describe DiscomfortCalculator do
@ -12,8 +14,7 @@ module Tables
describe '#calculate' do describe '#calculate' do
before do before do
allow(calculator).to receive(:table_size_penalty).and_return(2) allow(calculator).to receive_messages(table_size_penalty: 2, cohesion_discomfort: 3)
allow(calculator).to receive(:cohesion_discomfort).and_return(3)
end end
let(:table) { Table.new(create_list(:guest, 6)) } let(:table) { Table.new(create_list(:guest, 6)) }
@ -29,6 +30,7 @@ module Tables
table.min_per_table = 5 table.min_per_table = 5
table.max_per_table = 7 table.max_per_table = 7
end end
context 'when the number of guests is in the lower bound' do context 'when the number of guests is in the lower bound' do
let(:table) { Table.new(create_list(:guest, 5)) } let(:table) { Table.new(create_list(:guest, 5)) }
@ -88,6 +90,7 @@ module Tables
allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(friends.id, school.id).and_return(4) allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(friends.id, school.id).and_return(4)
allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(work.id, school.id).and_return(5) allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(work.id, school.id).and_return(5)
end end
context 'when the table contains just two guests' do context 'when the table contains just two guests' do
context 'when they belong to the same group' do context 'when they belong to the same group' do
let(:table) { create_list(:guest, 2, group: family) } let(:table) { create_list(:guest, 2, group: family) }
@ -102,6 +105,7 @@ module Tables
create(:guest, group: friends) create(:guest, group: friends)
] ]
end end
it { expect(calculator.send(:cohesion_discomfort)).to eq(1) } it { expect(calculator.send(:cohesion_discomfort)).to eq(1) }
end end
@ -205,7 +209,7 @@ module Tables
end end
it 'returns the sum of the penalties for each pair of guests' do it 'returns the sum of the penalties for each pair of guests' do
expect(calculator.send(:cohesion_discomfort)).to eq(4 * 1 + 4 * Rational(1, 2) + 4 * Rational(2, 3)) expect(calculator.send(:cohesion_discomfort)).to eq((4 * 1) + (4 * Rational(1, 2)) + (4 * Rational(2, 3)))
end end
end end
@ -219,7 +223,7 @@ module Tables
end end
it 'returns the sum of the penalties for each pair of guests' do it 'returns the sum of the penalties for each pair of guests' do
expect(calculator.send(:cohesion_discomfort)).to eq(6 * 1 + 2 * Rational(1, 2) + 3 * Rational(2, 3)) expect(calculator.send(:cohesion_discomfort)).to eq((6 * 1) + (2 * Rational(1, 2)) + (3 * Rational(2, 3)))
end end
end end
end end

View File

@ -1,23 +1,25 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Tables module Tables
RSpec.describe Distribution do RSpec.describe Distribution do
describe '#random_distribution' do describe '#random_distribution' do
let(:subject) { described_class.new(min_per_table: 5, max_per_table: 10) } subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) }
context 'when there are fewer people than the minimum per table' do context 'when there are fewer people than the minimum per table' do
it 'creates one table' do it 'creates one table' do
subject.random_distribution([1, 2, 3, 4]) distribution.random_distribution([1, 2, 3, 4])
expect(subject.tables.count).to eq(1) expect(distribution.tables.count).to eq(1)
end end
end end
context 'when there are more people than the maximum per table' do context 'when there are more people than the maximum per table' do
it 'creates multiple tables' do it 'creates multiple tables' do
subject.random_distribution([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) distribution.random_distribution([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
expect(subject.tables.count).to be > 1 expect(distribution.tables.count).to be > 1
end end
end end
end end

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Tables module Tables
@ -7,7 +9,7 @@ module Tables
describe '#each' do describe '#each' do
let(:shifts) do let(:shifts) do
acc = [] acc = []
described_class.new(initial_solution).each do |solution| described_class.new(initial_solution).each do |solution| # rubocop:disable Style/MapIntoArray -- #map is not implemented
acc << solution.tables.map(&:dup) acc << solution.tables.map(&:dup)
end end
acc acc

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module Tables module Tables
@ -7,7 +9,7 @@ module Tables
describe '#each' do describe '#each' do
let(:swaps) do let(:swaps) do
acc = [] acc = []
described_class.new(initial_solution).each do |solution| described_class.new(initial_solution).each do |solution| # rubocop:disable Style/MapIntoArray -- #map is not implemented
acc << solution.tables.map(&:dup) acc << solution.tables.map(&:dup)
end end
acc acc

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
module VNS module VNS

View File

@ -1,5 +1,7 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
# This file was generated by the `rails generate rspec:install` command. Conventionally, all # This file was generated by the `rails generate rspec:install` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause # The generated `.rspec` file contains `--require spec_helper` which will cause
@ -46,51 +48,49 @@ RSpec.configure do |config|
# triggering implicit auto-inclusion in groups with matching metadata. # triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience # The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content. # with RSpec, but feel free to customize to your heart's content.
=begin # # This allows you to limit a spec run to individual examples or groups
# This allows you to limit a spec run to individual examples or groups # # you care about by tagging them with `:focus` metadata. When nothing
# you care about by tagging them with `:focus` metadata. When nothing # # is tagged with `:focus`, all examples get run. RSpec also provides
# is tagged with `:focus`, all examples get run. RSpec also provides # # aliases for `it`, `describe`, and `context` that include `:focus`
# aliases for `it`, `describe`, and `context` that include `:focus` # # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
# metadata: `fit`, `fdescribe` and `fcontext`, respectively. # config.filter_run_when_matching :focus
config.filter_run_when_matching :focus #
# # Allows RSpec to persist some state between runs in order to support
# Allows RSpec to persist some state between runs in order to support # # the `--only-failures` and `--next-failure` CLI options. We recommend
# the `--only-failures` and `--next-failure` CLI options. We recommend # # you configure your source control system to ignore this file.
# you configure your source control system to ignore this file. # config.example_status_persistence_file_path = "spec/examples.txt"
config.example_status_persistence_file_path = "spec/examples.txt" #
# # Limits the available syntax to the non-monkey patched syntax that is
# Limits the available syntax to the non-monkey patched syntax that is # # recommended. For more details, see:
# recommended. For more details, see: # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ # config.disable_monkey_patching!
config.disable_monkey_patching! #
# # Many RSpec users commonly either run the entire suite or an individual
# Many RSpec users commonly either run the entire suite or an individual # # file, and it's useful to allow more verbose output when running an
# file, and it's useful to allow more verbose output when running an # # individual spec file.
# individual spec file. # if config.files_to_run.one?
if config.files_to_run.one? # # Use the documentation formatter for detailed output,
# Use the documentation formatter for detailed output, # # unless a formatter has already been configured
# unless a formatter has already been configured # # (e.g. via a command-line flag).
# (e.g. via a command-line flag). # config.default_formatter = "doc"
config.default_formatter = "doc" # end
end #
# # Print the 10 slowest examples and example groups at the
# Print the 10 slowest examples and example groups at the # # end of the spec run, to help surface which specs are running
# end of the spec run, to help surface which specs are running # # particularly slow.
# particularly slow. # config.profile_examples = 10
config.profile_examples = 10 #
# # Run specs in random order to surface order dependencies. If you find an
# Run specs in random order to surface order dependencies. If you find an # # order dependency and want to debug it, you can fix the order by providing
# order dependency and want to debug it, you can fix the order by providing # # the seed, which is printed after each run.
# the seed, which is printed after each run. # # --seed 1234
# --seed 1234 # config.order = :random
config.order = :random #
# # Seed global randomization in this process using the `--seed` CLI option.
# Seed global randomization in this process using the `--seed` CLI option. # # Setting this allows you to use `--seed` to deterministically reproduce
# Setting this allows you to use `--seed` to deterministically reproduce # # test failures related to randomization by passing the same `--seed` value
# test failures related to randomization by passing the same `--seed` value # # as the one that triggered the failure.
# as the one that triggered the failure. # Kernel.srand config.seed
Kernel.srand config.seed
=end
end end

View File

@ -3,10 +3,10 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
require_relative './swagger_response_helper' require_relative 'swagger_response_helper'
require_relative './requests/schemas.rb' require_relative 'requests/schemas'
include SwaggerResponseHelper include SwaggerResponseHelper # rubocop:disable Style/MixinUsage
RSpec.configure do |config| RSpec.configure do |config|
# Specify a root folder where Swagger JSON files are generated # Specify a root folder where Swagger JSON files are generated

View File

@ -1,18 +1,19 @@
# Copyright (C) 2024 Manuel Bustillo # Copyright (C) 2024 Manuel Bustillo
# frozen_string_literal: true
module SwaggerResponseHelper module SwaggerResponseHelper
TIMESTAMP_FORMAT = '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z' TIMESTAMP_FORMAT = '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'
TIMESTAMP_EXAMPLE = Time.zone.now.iso8601(3) TIMESTAMP_EXAMPLE = Time.zone.now.iso8601(3)
TIMESTAMP = {type: :string,pattern: TIMESTAMP_FORMAT,example: TIMESTAMP_EXAMPLE}.freeze TIMESTAMP = { type: :string, pattern: TIMESTAMP_FORMAT, example: TIMESTAMP_EXAMPLE }.freeze
PASSWORD = { type: :string, minLength: User.password_length.begin, maxLength: User.password_length.end } PASSWORD = { type: :string, minLength: User.password_length.begin, maxLength: User.password_length.end }.freeze
def regular_api_responses def regular_api_responses
response_401 response401
end end
def response_422 def response422
response(422, 'Validation errors in input parameters') do response(422, 'Validation errors in input parameters') do
produces 'application/json' produces 'application/json'
error_schema error_schema
@ -20,7 +21,7 @@ module SwaggerResponseHelper
end end
end end
def response_empty_200 def response_empty200
response(200, 'Success') do response(200, 'Success') do
produces 'application/json' produces 'application/json'
schema type: :object schema type: :object
@ -28,7 +29,7 @@ module SwaggerResponseHelper
end end
end end
def response_empty_201 def response_empty201
response(201, 'Created') do response(201, 'Created') do
produces 'application/json' produces 'application/json'
schema type: :object schema type: :object
@ -36,7 +37,7 @@ module SwaggerResponseHelper
end end
end end
def response_404 def response404
response(404, 'Record not found') do response(404, 'Record not found') do
produces 'application/json' produces 'application/json'
error_schema error_schema
@ -44,14 +45,14 @@ module SwaggerResponseHelper
end end
end end
def response_401(message: nil) def response401(message: nil)
response(401, 'Unauthorized') do response(401, 'Unauthorized') do
produces 'application/json' produces 'application/json'
schema type: :object, schema type: :object,
required: %i[error], required: %i[error],
properties: { properties: {
error: { type: :string, example: message || 'You need to sign in or sign up before continuing.' } error: { type: :string, example: message || 'You need to sign in or sign up before continuing.' }
} }
xit xit
end end
end end