diff --git a/Gemfile b/Gemfile index d3a0e96..0ba536b 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,9 @@ gem "json", "2.21.2" gem "active_model_serializers", "~> 0.10.16" +gem "sidekiq", "~> 8.0" + +gem "sidekiq-cron" # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible # gem "rack-cors" diff --git a/Gemfile.lock b/Gemfile.lock index 73894ba..0f92f19 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,6 +98,9 @@ GEM concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -117,6 +120,8 @@ GEM ed25519 (1.4.0) erb (6.0.7) erubi (1.13.1) + et-orbi (1.4.2) + tzinfo factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) @@ -124,6 +129,9 @@ GEM railties (>= 6.1.0) faker (3.8.0) i18n (>= 1.8.11, < 2) + fugit (1.14.0) + et-orbi (~> 1.4) + raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) i18n (1.15.2) @@ -200,6 +208,7 @@ GEM prism (1.9.0) puma (8.0.2) nio4r (~> 2.0) + raabro (1.5.0) racc (1.8.1) rack (3.2.7) rack-session (2.1.2) @@ -253,6 +262,8 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort + redis-client (0.31.0) + connection_pool regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) @@ -306,6 +317,17 @@ GEM rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) securerandom (0.4.1) + sidekiq (8.1.7) + connection_pool (>= 3.0.0) + json (>= 2.16.0) + logger (>= 1.7.0) + rack (>= 3.2.0) + redis-client (>= 0.29.0) + sidekiq-cron (2.4.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) + globalid (>= 1.0.1) + sidekiq (>= 6.5.0) solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) @@ -326,6 +348,7 @@ GEM concurrent-ruby (~> 1.0) tzinfo-data (1.2026.4) tzinfo (>= 1.0.0) + unicode (0.4.4.5) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -362,6 +385,8 @@ DEPENDENCIES rails-i18n rspec-rails rubocop-rails-omakase + sidekiq (~> 8.0) + sidekiq-cron solid_cache thruster tzinfo-data @@ -393,6 +418,7 @@ CHECKSUMS concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + cronex (0.15.0) sha256=21c794e085fad2951c4f2e279f440340a35ba2297e0b738f22f263f69fbe2186 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 devise (5.0.4) sha256=d605f2b85854e74e56ee789e2d398702bc2d06e6bcd894717a670a3199c74cc1 @@ -403,9 +429,11 @@ CHECKSUMS ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + fugit (1.14.0) sha256=e311a9cc85a54bb67649df45060e721282a1e2c8ebad40e41874a3a7d576f37e globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 @@ -443,6 +471,7 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 @@ -457,6 +486,7 @@ CHECKSUMS rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a + redis-client (0.31.0) sha256=6820cb2a21e6795a823141f50fd25e655de2b8dc8ff833b43509a27b0263fd75 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d responders (3.2.1) sha256=c2772970cbda654c0e772d852e5a851e82cd8e96f5fa958650861ba75b8ffa84 @@ -472,6 +502,8 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sidekiq (8.1.7) sha256=91ad48c7e6c32e1846bf1d5b74c27948758049b93fed0cb539f8d586c7de3415 + sidekiq-cron (2.4.0) sha256=25595c5aee0c16d13a89466e5108af047fa4cfbf388199b39e1a600ca39472d4 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 @@ -481,6 +513,7 @@ CHECKSUMS tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo-data (1.2026.4) sha256=22f5f02608f14938ab8b78513a70a22f0b0277614a07273d3bedb3ea45ece104 + unicode (0.4.4.5) sha256=42f294bfc8e186d29da89d1f766071505a20a22776168a31bb3408e03fa7a9d7 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 diff --git a/app/controllers/api/v1/groups_controller.rb b/app/controllers/api/v1/groups_controller.rb index 282b808..e3a2917 100644 --- a/app/controllers/api/v1/groups_controller.rb +++ b/app/controllers/api/v1/groups_controller.rb @@ -28,7 +28,7 @@ def update end def destroy - Groups::Delete.call(id: params[:id]) + Groups::Deactivate.call(id: params[:id]) head :no_content end diff --git a/app/controllers/api/v1/occupations_controller.rb b/app/controllers/api/v1/occupations_controller.rb new file mode 100644 index 0000000..d5ac4b8 --- /dev/null +++ b/app/controllers/api/v1/occupations_controller.rb @@ -0,0 +1,38 @@ +module Api + module V1 + class OccupationsController < BaseController + before_action :authenticate_request! + + def create + group = Groups::Find.call(id: occupation_params[:group_id]) + destination = Terrains::Find.call(id: occupation_params[:terrain_id]) + + occupation = Occupations::Move.call(group:, destination:) + + render json: occupation, serializer: OccupationSerializer, status: :created + end + + def by_group + group = Groups::Find.call(id: params[:id]) + result = Occupations::HistoryByGroup.call(group:, page: params[:page], per_page: params[:per_page]) + paginated = PaginatedCollectionSerializer.call(result:, serializer: GroupOccupationHistorySerializer) + + render json: { group: GroupSummarySerializer.new(group).attributes, **paginated } + end + + def by_terrain + terrain = Terrains::Find.call(id: params[:id]) + result = Occupations::HistoryByTerrain.call(terrain:, page: params[:page], per_page: params[:per_page]) + paginated = PaginatedCollectionSerializer.call(result:, serializer: TerrainOccupationHistorySerializer) + + render json: { terrain: TerrainSummarySerializer.new(terrain).attributes, **paginated } + end + + private + + def occupation_params + params.expect(occupation: [ :group_id, :terrain_id ]) + end + end + end +end diff --git a/app/controllers/api/v1/terrains_controller.rb b/app/controllers/api/v1/terrains_controller.rb index 05a920a..bec63cb 100644 --- a/app/controllers/api/v1/terrains_controller.rb +++ b/app/controllers/api/v1/terrains_controller.rb @@ -28,7 +28,7 @@ def update end def destroy - Terrains::Delete.call(id: params[:id]) + Terrains::Deactivate.call(id: params[:id]) head :no_content end diff --git a/app/errors/occupations/deactivation_error.rb b/app/errors/occupations/deactivation_error.rb new file mode 100644 index 0000000..b411c40 --- /dev/null +++ b/app/errors/occupations/deactivation_error.rb @@ -0,0 +1,4 @@ +module Occupations + class DeactivationError < ApplicationError + end +end diff --git a/app/errors/occupations/movement_error.rb b/app/errors/occupations/movement_error.rb new file mode 100644 index 0000000..6cace8d --- /dev/null +++ b/app/errors/occupations/movement_error.rb @@ -0,0 +1,4 @@ +module Occupations + class MovementError < ApplicationError + end +end diff --git a/app/jobs/terrains/release_resting_job.rb b/app/jobs/terrains/release_resting_job.rb new file mode 100644 index 0000000..5c554de --- /dev/null +++ b/app/jobs/terrains/release_resting_job.rb @@ -0,0 +1,9 @@ +module Terrains + class ReleaseRestingJob < ApplicationJob + queue_as :default + + def perform + Terrains::ReleaseResting.call + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 0cb5049..cbaaee8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,4 +1,6 @@ class Group < ApplicationRecord + has_many :occupations, dependent: :restrict_with_error + validates :name, presence: true validates :animal_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -12,4 +14,12 @@ def deleted? def soft_delete! update!(deleted_at: Time.current) end + + def current_occupation + occupations.active.first + end + + def current_terrain + current_occupation&.terrain + end end diff --git a/app/models/occupation.rb b/app/models/occupation.rb new file mode 100644 index 0000000..a1eea89 --- /dev/null +++ b/app/models/occupation.rb @@ -0,0 +1,27 @@ +class Occupation < ApplicationRecord + belongs_to :group + belongs_to :terrain + + validates :started_at, presence: true + validate :ended_at_not_before_started_at + + scope :active, -> { where(ended_at: nil) } + scope :finished, -> { where.not(ended_at: nil) } + + def active? + ended_at.nil? + end + + def finished? + !active? + end + + private + + def ended_at_not_before_started_at + return if ended_at.blank? || started_at.blank? + return if ended_at >= started_at + + errors.add(:ended_at, :greater_than_or_equal_to, count: started_at) + end +end diff --git a/app/models/terrain.rb b/app/models/terrain.rb index 5b4a3d2..65c54d3 100644 --- a/app/models/terrain.rb +++ b/app/models/terrain.rb @@ -1,4 +1,6 @@ class Terrain < ApplicationRecord + has_many :occupations, dependent: :restrict_with_error + enum :status, { available: "available", occupied: "occupied", @@ -19,4 +21,49 @@ def deleted? def soft_delete! update!(deleted_at: Time.current) end + + def current_occupation + occupations.active.first + end + + def current_group + current_occupation&.group + end + + def last_occupation + occupations.finished.order(ended_at: :desc).first + end + + def last_group + last_occupation&.group + end + + def remaining_rest_days + return 0 unless resting? + + occupation = last_occupation + return 0 unless occupation + + elapsed_days = (Date.current - occupation.ended_at.to_date).to_i + + [ rest_days - elapsed_days, 0 ].max + end + + def days_free + return 0 unless available? + + occupation = last_occupation + + return (Date.current - created_at.to_date).to_i unless occupation + + (Date.current - occupation.ended_at.to_date).to_i + end + + def rest_completed? + return false unless resting? + + occupation = last_occupation + return false unless occupation + occupation.ended_at.to_date + rest_days <= Date.current + end end diff --git a/app/serializers/group_occupation_history_serializer.rb b/app/serializers/group_occupation_history_serializer.rb new file mode 100644 index 0000000..79e860c --- /dev/null +++ b/app/serializers/group_occupation_history_serializer.rb @@ -0,0 +1,5 @@ +class GroupOccupationHistorySerializer < ActiveModel::Serializer + attributes :id, :started_at, :ended_at + + belongs_to :terrain, serializer: TerrainSummarySerializer +end diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb index 61325bb..144937c 100644 --- a/app/serializers/group_serializer.rb +++ b/app/serializers/group_serializer.rb @@ -1,3 +1,5 @@ class GroupSerializer < ActiveModel::Serializer attributes :id, :name, :animal_count, :created_at, :updated_at + + belongs_to :current_terrain, serializer: TerrainSummarySerializer end diff --git a/app/serializers/group_summary_serializer.rb b/app/serializers/group_summary_serializer.rb new file mode 100644 index 0000000..7b31ab3 --- /dev/null +++ b/app/serializers/group_summary_serializer.rb @@ -0,0 +1,3 @@ +class GroupSummarySerializer < ActiveModel::Serializer + attributes :id, :name +end diff --git a/app/serializers/occupation_serializer.rb b/app/serializers/occupation_serializer.rb new file mode 100644 index 0000000..dc77a32 --- /dev/null +++ b/app/serializers/occupation_serializer.rb @@ -0,0 +1,6 @@ +class OccupationSerializer < ActiveModel::Serializer + attributes :id, :started_at, :ended_at + + belongs_to :group, serializer: GroupSummarySerializer + belongs_to :terrain, serializer: TerrainSummarySerializer +end diff --git a/app/serializers/terrain_occupation_history_serializer.rb b/app/serializers/terrain_occupation_history_serializer.rb new file mode 100644 index 0000000..2e70ef9 --- /dev/null +++ b/app/serializers/terrain_occupation_history_serializer.rb @@ -0,0 +1,5 @@ +class TerrainOccupationHistorySerializer < ActiveModel::Serializer + attributes :id, :started_at, :ended_at + + belongs_to :group, serializer: GroupSummarySerializer +end diff --git a/app/serializers/terrain_serializer.rb b/app/serializers/terrain_serializer.rb index 576883b..c735c94 100644 --- a/app/serializers/terrain_serializer.rb +++ b/app/serializers/terrain_serializer.rb @@ -1,3 +1,52 @@ class TerrainSerializer < ActiveModel::Serializer - attributes :id, :name, :rest_days, :status, :created_at, :updated_at + attributes :id, :name, :rest_days, :status, :state_details, :created_at, :updated_at + + def state_details + return occupied_details if object.occupied? + return resting_details if object.resting? + + available_details + end + + private + + def occupied_details + occupation = object.current_occupation + { + current_group: serialize_group(object.current_group), + entered_at: occupation&.started_at + } + end + + def resting_details + { + remaining_rest_days: object.remaining_rest_days, + last_group: serialize_group(object.last_group), + last_occupation: serialize_occupation(object.last_occupation) + } + end + + def available_details + { + days_free: object.days_free, + last_group: serialize_group(object.last_group), + last_occupation: serialize_occupation(object.last_occupation) + } + end + + def serialize_group(group) + return nil unless group + + GroupSummarySerializer.new(group).attributes + end + + def serialize_occupation(occupation) + return nil unless occupation + + { + id: occupation.id, + started_at: occupation.started_at, + ended_at: occupation.ended_at + } + end end diff --git a/app/serializers/terrain_summary_serializer.rb b/app/serializers/terrain_summary_serializer.rb new file mode 100644 index 0000000..a630542 --- /dev/null +++ b/app/serializers/terrain_summary_serializer.rb @@ -0,0 +1,3 @@ +class TerrainSummarySerializer < ActiveModel::Serializer + attributes :id, :name +end diff --git a/app/services/groups/deactivate.rb b/app/services/groups/deactivate.rb new file mode 100644 index 0000000..1579898 --- /dev/null +++ b/app/services/groups/deactivate.rb @@ -0,0 +1,23 @@ +module Groups + class Deactivate + class << self + def call(id:) + ActiveRecord::Base.transaction do + group = Group.active.lock.find(id) + occupation = group.occupations.active.lock.first + close_occupation(occupation) if occupation + group.soft_delete! + group + end + end + + private + + def close_occupation(occupation) + occupation.terrain.lock! + Occupations::Close.call(occupation:) + Terrains::StartRest.call(terrain: occupation.terrain) + end + end + end +end diff --git a/app/services/groups/delete.rb b/app/services/groups/delete.rb deleted file mode 100644 index 2c4b0be..0000000 --- a/app/services/groups/delete.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Groups - class Delete - class << self - def call(id:) - group = Groups::Find.call(id:) - group.soft_delete! - group - end - end - end -end diff --git a/app/services/occupations/close.rb b/app/services/occupations/close.rb new file mode 100644 index 0000000..e865fe2 --- /dev/null +++ b/app/services/occupations/close.rb @@ -0,0 +1,10 @@ +module Occupations + class Close + class << self + def call(occupation:) + occupation.update!(ended_at: Time.current) + occupation + end + end + end +end diff --git a/app/services/occupations/history_by_group.rb b/app/services/occupations/history_by_group.rb new file mode 100644 index 0000000..e53e173 --- /dev/null +++ b/app/services/occupations/history_by_group.rb @@ -0,0 +1,11 @@ +module Occupations + class HistoryByGroup + class << self + def call(group:, page:, per_page:) + scope = group.occupations.includes(:group, :terrain).order(started_at: :desc) + + Pagination::Paginator.call(scope:, page:, per_page:) + end + end + end +end diff --git a/app/services/occupations/history_by_terrain.rb b/app/services/occupations/history_by_terrain.rb new file mode 100644 index 0000000..67a1334 --- /dev/null +++ b/app/services/occupations/history_by_terrain.rb @@ -0,0 +1,11 @@ +module Occupations + class HistoryByTerrain + class << self + def call(terrain:, page:, per_page:) + scope = terrain.occupations.includes(:group, :terrain).order(started_at: :desc) + + Pagination::Paginator.call(scope:, page:, per_page:) + end + end + end +end diff --git a/app/services/occupations/move.rb b/app/services/occupations/move.rb new file mode 100644 index 0000000..3339bc9 --- /dev/null +++ b/app/services/occupations/move.rb @@ -0,0 +1,37 @@ +module Occupations + class Move + class << self + def call(group:, destination:) + ActiveRecord::Base.transaction do + group.lock! + + current_occupation = group.occupations.active.lock.first + lock_terrains!(current_occupation, destination) + + MovementPolicy.validate!(group:, current_occupation:, destination:) + + close_current_occupation(current_occupation) + + occupation = Open.call(group:, terrain: destination) + Terrains::Occupy.call(terrain: destination) + + occupation + end + end + + private + + def close_current_occupation(occupation) + return unless occupation + + Close.call(occupation:) + Terrains::StartRest.call(terrain: occupation.terrain) + end + + def lock_terrains!(current_occupation, destination) + terrains_ids = [ current_occupation&.terrain_id, destination.id ].compact.uniq.sort + Terrain.where(id: terrains_ids).order(:id).lock.load + end + end + end +end diff --git a/app/services/occupations/movement_policy.rb b/app/services/occupations/movement_policy.rb new file mode 100644 index 0000000..fe8e7d6 --- /dev/null +++ b/app/services/occupations/movement_policy.rb @@ -0,0 +1,40 @@ +module Occupations + class MovementPolicy + class << self + def validate!(group:, current_occupation:, destination:) + validate_group!(group) + validate_destination!(destination) + validate_same_terrain!(current_occupation, destination) + validate_destination_status!(destination) + + true + end + + private + + def validate_group!(group) + return unless group.deleted? + + raise Occupations::MovementError, I18n.t("errors.occupations.inactive_group") + end + + def validate_destination!(destination) + return unless destination.deleted? + + raise Occupations::MovementError, I18n.t("errors.occupations.inactive_terrain") + end + + def validate_same_terrain!(current_occupation, destination) + return unless current_occupation&.terrain_id == destination.id + + raise Occupations::MovementError, I18n.t("errors.occupations.same_terrain") + end + + def validate_destination_status!(destination) + return unless destination.occupied? + + raise Occupations::MovementError, I18n.t("errors.occupations.terrain_occupied") + end + end + end +end diff --git a/app/services/occupations/open.rb b/app/services/occupations/open.rb new file mode 100644 index 0000000..396fcfa --- /dev/null +++ b/app/services/occupations/open.rb @@ -0,0 +1,13 @@ +module Occupations + class Open + class << self + def call(group:, terrain:) + Occupation.create!( + group:, + terrain:, + started_at: Time.current, + ) + end + end + end +end diff --git a/app/services/terrains/deactivate.rb b/app/services/terrains/deactivate.rb new file mode 100644 index 0000000..f943040 --- /dev/null +++ b/app/services/terrains/deactivate.rb @@ -0,0 +1,22 @@ +module Terrains + class Deactivate + class << self + def call(id:) + ActiveRecord::Base.transaction do + terrain = Terrain.active.lock.find(id) + validate_deactivation!(terrain) + terrain.soft_delete! + terrain + end + end + + private + + def validate_deactivation!(terrain) + return unless terrain.occupations.active.exists? + + raise Occupations::DeactivationError, I18n.t("occupations.terrain_occupied") + end + end + end +end diff --git a/app/services/terrains/delete.rb b/app/services/terrains/delete.rb deleted file mode 100644 index 6111a74..0000000 --- a/app/services/terrains/delete.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Terrains - class Delete - class << self - def call(id:) - terrain = Terrains::Find.call(id:) - terrain.soft_delete! - terrain - end - end - end -end diff --git a/app/services/terrains/occupy.rb b/app/services/terrains/occupy.rb new file mode 100644 index 0000000..a51d1db --- /dev/null +++ b/app/services/terrains/occupy.rb @@ -0,0 +1,10 @@ +module Terrains + class Occupy + class << self + def call(terrain:) + terrain.update!(status: :occupied) + terrain + end + end + end +end diff --git a/app/services/terrains/release.rb b/app/services/terrains/release.rb new file mode 100644 index 0000000..b21bccd --- /dev/null +++ b/app/services/terrains/release.rb @@ -0,0 +1,10 @@ +module Terrains + class Release + class << self + def call(terrain:) + terrain.update!(status: :available) + terrain + end + end + end +end diff --git a/app/services/terrains/release_resting.rb b/app/services/terrains/release_resting.rb new file mode 100644 index 0000000..5a2e4e7 --- /dev/null +++ b/app/services/terrains/release_resting.rb @@ -0,0 +1,13 @@ +module Terrains + class ReleaseResting + class << self + def call + Terrain.resting.find_each do |terrain| + next unless terrain.rest_completed? + + Terrains::Release.call(terrain:) + end + end + end + end +end diff --git a/app/services/terrains/start_rest.rb b/app/services/terrains/start_rest.rb new file mode 100644 index 0000000..68a5272 --- /dev/null +++ b/app/services/terrains/start_rest.rb @@ -0,0 +1,15 @@ +module Terrains + class StartRest + class << self + def call(terrain:) + if terrain.rest_days.zero? + Terrains::Release.call(terrain:) + else + terrain.update!(status: :resting) + end + + terrain + end + end + end +end diff --git a/compose.yml b/compose.yml index 3d5a4d8..92c48d3 100644 --- a/compose.yml +++ b/compose.yml @@ -15,6 +15,18 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + + volumes: + - redis_data:/data + + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + api: build: context: . @@ -34,10 +46,35 @@ services: DB_PORT: 5432 DB_USER: postgres DB_PASSWORD: postgres + REDIS_URL: redis://redis:6379/0 + + depends_on: + - db + - redis + + sidekiq: + build: + context: . + dockerfile: Dockerfile.dev + + command: bundle exec sidekiq -C config/sidekiq.yml + + volumes: + - .:/rails + - bundle_data:/usr/local/bundle + + environment: + DB_HOST: db + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + REDIS_URL: redis://redis:6379/0 depends_on: - db + - redis volumes: postgres_data: + redis_data: bundle_data: diff --git a/config/application.rb b/config/application.rb index c96b611..31f7c11 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,8 @@ class Application < Rails::Application config.log_tags = [ :request_id ] + config.active_job.queue_adapter = :sidekiq + config.generators do |g| g.test_framework :rspec, fixtures: false, diff --git a/config/initializers/sidekiq_cron.rb b/config/initializers/sidekiq_cron.rb new file mode 100644 index 0000000..fd30459 --- /dev/null +++ b/config/initializers/sidekiq_cron.rb @@ -0,0 +1,9 @@ +require "sidekiq/cron/job" + +Sidekiq.configure_server do |config| + config.on(:startup) do + schedule_file = Rails.root.join("config", "sidekiq_schedule.yml") + schedule = YAML.load_file(schedule_file) + Sidekiq::Cron::Job.load_from_hash!(schedule) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index ff2091a..014a79d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,6 +12,11 @@ en: password_reset_verify: "Rate limit exceeded for password reset verification." resource: not_found: "Resource not found." + occupations: + inactive_group: "The group is inactive." + inactive_terrain: "The terrain is inactive." + same_terrain: "The group is already in this terrain." + terrain_occupied: "The terrain is occupied." activerecord: models: user: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index aa6c57a..ce76340 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -12,6 +12,11 @@ pt-BR: password_reset_verify: "Limite de solicitações excedido para verificação de redefinição de senha." resource: not_found: "Recurso não encontrado." + occupations: + inactive_group: "O grupo está inativo." + inactive_terrain: "O terreno está inativo." + same_terrain: "O grupo já está neste terreno." + terrain_occupied: "O terreno está ocupado." activerecord: errors: messages: diff --git a/config/routes.rb b/config/routes.rb index 73add0f..080a0a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,8 +16,16 @@ get "me", to: "sessions#show" delete "logout", to: "sessions#destroy" end - resources :groups, only: [ :create, :index, :show, :update, :destroy ] - resources :terrains, only: [ :create, :index, :show, :update, :destroy ] + + resources :groups, only: [ :create, :index, :show, :update, :destroy ] do + get :occupations, to: "occupations#by_group", on: :member + end + + resources :terrains, only: [ :create, :index, :show, :update, :destroy ] do + get :occupations, to: "occupations#by_terrain", on: :member + end + + resources :occupations, only: [ :create ] get "health", to: "health#show" end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..19a18d0 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,4 @@ +:concurrency: 5 + +:queues: + - default \ No newline at end of file diff --git a/config/sidekiq_schedule.yml b/config/sidekiq_schedule.yml new file mode 100644 index 0000000..5632ac8 --- /dev/null +++ b/config/sidekiq_schedule.yml @@ -0,0 +1,5 @@ +release_resting_terrains: + cron: "0 */12 * * *" + class: "Terrains::ReleaseRestingJob" + queue: default + description: "Libera terrenos que concluíram o período de descanso" \ No newline at end of file diff --git a/db/migrate/20261022181113_create_occupations.rb b/db/migrate/20261022181113_create_occupations.rb new file mode 100644 index 0000000..771147c --- /dev/null +++ b/db/migrate/20261022181113_create_occupations.rb @@ -0,0 +1,15 @@ +class CreateOccupations < ActiveRecord::Migration[8.1] + def change + create_table :occupations, id: :uuid do |t| + t.references :group, null: false, foreign_key: true, type: :uuid + t.references :terrain, null: false, foreign_key: true, type: :uuid + t.datetime :started_at, null: false + t.datetime :ended_at + + t.timestamps + end + + add_index :occupations, :group_id, unique: true, where: "ended_at IS NULL", name: "index_occupations_on_active_group" + add_index :occupations, :terrain_id, unique: true, where: "ended_at IS NULL", name: "index_occupations_on_active_terrain" + end +end diff --git a/db/schema.rb b/db/schema.rb index 60df6c4..c4d8d3c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_30_115218) do +ActiveRecord::Schema[8.1].define(version: 2026_10_22_181113) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -24,6 +24,19 @@ t.index ["deleted_at"], name: "index_groups_on_deleted_at" end + create_table "occupations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "group_id", null: false + t.uuid "terrain_id", null: false + t.datetime "started_at", null: false + t.datetime "ended_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["group_id"], name: "index_occupations_on_active_group", unique: true, where: "(ended_at IS NULL)" + t.index ["group_id"], name: "index_occupations_on_group_id" + t.index ["terrain_id"], name: "index_occupations_on_active_terrain", unique: true, where: "(ended_at IS NULL)" + t.index ["terrain_id"], name: "index_occupations_on_terrain_id" + end + create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id", null: false t.string "refresh_token_digest", null: false @@ -58,5 +71,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "occupations", "groups" + add_foreign_key "occupations", "terrains" add_foreign_key "sessions", "users" end diff --git a/spec/factories/occupations.rb b/spec/factories/occupations.rb new file mode 100644 index 0000000..4a0dc75 --- /dev/null +++ b/spec/factories/occupations.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :occupation do + association :group + association :terrain + + started_at { 1.days.ago } + ended_at { nil } + + trait :finished do + started_at { 2.days.ago } + ended_at { 1.days.ago } + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 796a914..81b7e8c 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -67,4 +67,21 @@ expect(group.reload.deleted?).to be true end end + + describe '#current_occupation' do + it "returns the active occupation" do + group = create(:group) + occupation = create(:occupation, group:) + expect(group.current_occupation).to eq(occupation) + end + end + + describe '#current_terrain' do + it "returns the terrain of the active occupation" do + group = create(:group) + terrain = create(:terrain) + create(:occupation, group:, terrain:) + expect(group.current_terrain).to eq(terrain) + end + end end diff --git a/spec/models/occupation_spec.rb b/spec/models/occupation_spec.rb new file mode 100644 index 0000000..fc37941 --- /dev/null +++ b/spec/models/occupation_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe Occupation, type: :model do + describe 'validations' do + it "is valid with valid attributes" do + occupation = build(:occupation) + expect(occupation).to be_valid + end + + it "is not valid without a group" do + occupation = build(:occupation, group: nil) + expect(occupation).to_not be_valid + expect(occupation.errors[:group]).to be_present + end + + it "is not valid without a terrain" do + occupation = build(:occupation, terrain: nil) + expect(occupation).to_not be_valid + expect(occupation.errors[:terrain]).to be_present + end + + it "is not valid without a started_at" do + occupation = build(:occupation, started_at: nil) + expect(occupation).to_not be_valid + expect(occupation.errors[:started_at]).to be_present + end + + it "is not valid when ended_at is before started_at" do + occupation = build(:occupation, started_at: Time.current, ended_at: 1.days.ago) + expect(occupation).to_not be_valid + expect(occupation.errors[:ended_at]).to be_present + end + end + + describe '.active' do + it "returns only active occupations" do + active_occupation = create(:occupation) + create(:occupation, :finished) + expect(described_class.active).to contain_exactly(active_occupation) + end + end + + describe '.finished' do + it "returns only finished occupations" do + finished_occupation = create(:occupation, :finished) + create(:occupation) + expect(described_class.finished).to contain_exactly(finished_occupation) + end + end + + describe '#active?' do + it "returns true for active occupations" do + occupation = build(:occupation) + expect(occupation.active?).to be true + end + end + + describe '#finished?' do + it "returns true for finished occupations" do + occupation = build(:occupation, :finished) + expect(occupation.finished?).to be true + end + end +end diff --git a/spec/models/terrain_spec.rb b/spec/models/terrain_spec.rb index 3bfb279..70c1fbf 100644 --- a/spec/models/terrain_spec.rb +++ b/spec/models/terrain_spec.rb @@ -64,4 +64,76 @@ expect(terrain.reload.deleted?).to be(true) end end + + describe '#current_occupation' do + it 'returns the active occupation' do + terrain = create(:terrain) + occupation = create(:occupation, terrain:) + expect(terrain.current_occupation).to eq(occupation) + end + end + + describe '#current_group' do + it 'returns the group of the active occupation' do + terrain = create(:terrain) + group = create(:group) + create(:occupation, terrain:, group:) + expect(terrain.current_group).to eq(group) + end + end + + describe '#last_occupation' do + it "returns the most recent finished occupation" do + terrain = create(:terrain) + create(:occupation, terrain:, started_at: 4.days.ago, ended_at: 3.days.ago) + latest_occupation = create(:occupation, terrain:, started_at: 2.days.ago, ended_at: 1.day.ago) + expect(terrain.last_occupation).to eq(latest_occupation) + end + end + + describe '#last_group' do + it "returns the group of the most recent finished occupation" do + terrain = create(:terrain) + group = create(:group) + create(:occupation, terrain:, group:, started_at: 2.days.ago, ended_at: 1.days.ago) + expect(terrain.last_group).to eq(group) + end + end + + describe '#remaining_rest_days' do + it "returns the remaining rest days" do + terrain = create(:terrain, :resting, rest_days: 15) + create(:occupation, terrain:, started_at: 6.days.ago, ended_at: 5.days.ago) + expect(terrain.remaining_rest_days).to eq(10) + end + + it "returns 0 if the rest period has already passed" do + terrain = create(:terrain, :resting, rest_days: 5) + create(:occupation, terrain:, started_at: 11.days.ago, ended_at: 10.days.ago) + expect(terrain.remaining_rest_days).to eq(0) + end + + it "returns 0 if the terrain is not resting" do + terrain = create(:terrain) + expect(terrain.remaining_rest_days).to eq(0) + end + end + + describe '#days_free' do + it "returns the number of days since the last occupation ended" do + terrain = create(:terrain) + create(:occupation, terrain:, started_at: 6.days.ago, ended_at: 5.days.ago) + expect(terrain.days_free).to eq(5) + end + + it "returns the number of days since creation if there are no occupations" do + terrain = create(:terrain, created_at: 5.days.ago) + expect(terrain.days_free).to eq(5) + end + + it "returns 0 if the terrain is not available" do + terrain = create(:terrain, :resting) + expect(terrain.days_free).to eq(0) + end + end end diff --git a/spec/requests/api/v1/groups/destroy_spec.rb b/spec/requests/api/v1/groups/destroy_spec.rb index 2534ebd..c230899 100644 --- a/spec/requests/api/v1/groups/destroy_spec.rb +++ b/spec/requests/api/v1/groups/destroy_spec.rb @@ -20,6 +20,15 @@ expect(response).to have_http_status(:not_found) end + it "removes the group from its current terrain" do + group = create(:group) + terrain = create(:terrain, :occupied) + occupation = create(:occupation, group:, terrain:) + delete "/api/v1/groups/#{group.id}", headers: headers + expect(response).to have_http_status(:no_content) + expect(occupation.reload).to be_finished + end + it "returns unauthorized when no token is provided" do group = create(:group) delete "/api/v1/groups/#{group.id}" diff --git a/spec/requests/api/v1/occupations/create_spec.rb b/spec/requests/api/v1/occupations/create_spec.rb new file mode 100644 index 0000000..f94c22d --- /dev/null +++ b/spec/requests/api/v1/occupations/create_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +RSpec.describe "POST /api/v1/occupations", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + let(:group) { create(:group) } + let(:terrain) { create(:terrain) } + + it "creates the current occupation" do + post "/api/v1/occupations", + params: { + occupation: { group_id: group.id, terrain_id: terrain.id } + }, headers: headers, as: :json + + body = response.parsed_body + expect(response).to have_http_status(:created) + expect(body).to include( + "id" => be_present, + "started_at" => be_present, + "ended_at" => nil + ) + expect(body["group"]).to include( + "id" => group.id, + "name" => group.name + ) + expect(body["terrain"]).to include( + "id" => terrain.id, + "name" => terrain.name + ) + end + + it "returns not found when the group does not exist" do + post "/api/v1/occupations", + params: { + occupation: { group_id: SecureRandom.uuid, terrain_id: terrain.id } + }, headers: headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it "returns not found when the terrain does not exist" do + post "/api/v1/occupations", + params: { + occupation: { group_id: group.id, terrain_id: SecureRandom.uuid } + }, headers: headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it "returns unauthorized without valid authentication" do + post "/api/v1/occupations", + params: { + occupation: { group_id: group.id, terrain_id: terrain.id } + }, as: :json + + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/requests/api/v1/occupations/history_by_group_spec.rb b/spec/requests/api/v1/occupations/history_by_group_spec.rb new file mode 100644 index 0000000..3578c2c --- /dev/null +++ b/spec/requests/api/v1/occupations/history_by_group_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe "GET /api/v1/groups/:id/occupations", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + it "returns the occupations for the group" do + group = create(:group) + occupation = create(:occupation, group:) + get "/api/v1/groups/#{group.id}/occupations", headers: headers + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body["data"].pluck("id")).to eq([ occupation.id ]) + end + + it "returns unauthorized without valid authentication" do + group = create(:group) + get "/api/v1/groups/#{group.id}/occupations" + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/requests/api/v1/occupations/history_by_terrain_spec.rb b/spec/requests/api/v1/occupations/history_by_terrain_spec.rb new file mode 100644 index 0000000..eeab8c3 --- /dev/null +++ b/spec/requests/api/v1/occupations/history_by_terrain_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe "GET /api/v1/terrains/:id/occupations", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + it "returns the occupations for the terrain" do + terrain = create(:terrain) + occupation = create(:occupation, terrain:) + get "/api/v1/terrains/#{terrain.id}/occupations", headers: headers + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body["data"].pluck("id")).to eq([ occupation.id ]) + end + + it "returns unauthorized without valid authentication" do + terrain = create(:terrain) + get "/api/v1/terrains/#{terrain.id}/occupations" + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/spec/requests/api/v1/terrains/destroy_spec.rb b/spec/requests/api/v1/terrains/destroy_spec.rb index 2e7c5ed..8928f29 100644 --- a/spec/requests/api/v1/terrains/destroy_spec.rb +++ b/spec/requests/api/v1/terrains/destroy_spec.rb @@ -21,6 +21,14 @@ expect(response).to have_http_status(:not_found) end + it "does not deactivate an occupied terrain" do + terrain = create(:terrain, :occupied) + create(:occupation, terrain:) + delete "/api/v1/terrains/#{terrain.id}", headers: headers + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["errors"]["base"]).to be_present + end + it "returns unauthorized when no token is provided" do terrain = create(:terrain) delete "/api/v1/terrains/#{terrain.id}" diff --git a/spec/services/groups/deactivate_spec.rb b/spec/services/groups/deactivate_spec.rb new file mode 100644 index 0000000..269e367 --- /dev/null +++ b/spec/services/groups/deactivate_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Groups::Deactivate, type: :service do + describe '.call' do + it "deactivates the group" do + group = create(:group) + result = described_class.call(id: group.id) + expect(result.reload).to be_deleted + end + + it "closes the current occupation when the group is occupying a terrain" do + group = create(:group) + terrain = create(:terrain, :occupied) + occupation = create(:occupation, group:, terrain:) + described_class.call(id: group.id) + expect(occupation.reload).to be_finished + end + + it "starts the terrain rest when the group is removed" do + group = create(:group) + terrain = create(:terrain, :occupied, rest_days: 10) + create(:occupation, group:, terrain:) + described_class.call(id: group.id) + expect(terrain.reload).to be_resting + end + + it "makes the terrain available when rest_days is 0" do + group = create(:group) + terrain = create(:terrain, :occupied, rest_days: 0) + create(:occupation, group:, terrain:) + described_class.call(id: group.id) + expect(terrain.reload).to be_available + end + + it "raises an error when the group does not exist" do + expect { + described_class.call(id: SecureRandom.uuid) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/services/groups/delete_spec.rb b/spec/services/groups/delete_spec.rb deleted file mode 100644 index 9fa3d64..0000000 --- a/spec/services/groups/delete_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -require 'rails_helper' - - -RSpec.describe Groups::Delete, type: :service do - describe '.call' do - it "soft deletes the group" do - group = create(:group) - result = described_class.call(id: group.id) - expect(result).to be_deleted - expect(result.deleted_at).to be_present - end - - it "keeps the group persisted" do - group = create(:group) - expect { described_class.call(id: group.id) }.not_to change(Group, :count) - end - - it "removes the group from the active scope" do - group = create(:group) - described_class.call(id: group.id) - expect(Group.active).not_to include(group) - end - - it "raises an error if the group is already deleted" do - group = create(:group, :deleted) - expect { - described_class.call(id: group.id) - }.to raise_error(ActiveRecord::RecordNotFound) - end - - it "raises an error if the group is not found" do - expect { - described_class.call(id: 999) - }.to raise_error(ActiveRecord::RecordNotFound) - end - end -end diff --git a/spec/services/occupations/close_spec.rb b/spec/services/occupations/close_spec.rb new file mode 100644 index 0000000..9dd0cea --- /dev/null +++ b/spec/services/occupations/close_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Occupations::Close, type: :service do + describe '.call' do + it "closes the occupation" do + occupation = create(:occupation) + result = described_class.call(occupation: occupation) + expect(result.ended_at).to be_present + expect(result.finished?).to be true + end + + it "persists the occupation end" do + occupation = create(:occupation) + described_class.call(occupation:) + expect(occupation.reload.ended_at).to be_present + end + + it "keeps the occupation persisted" do + occupation = create(:occupation) + expect { described_class.call(occupation:) }.not_to change(Occupation, :count) + end + end +end diff --git a/spec/services/occupations/history_by_group_spec.rb b/spec/services/occupations/history_by_group_spec.rb new file mode 100644 index 0000000..6234ab7 --- /dev/null +++ b/spec/services/occupations/history_by_group_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Occupations::HistoryByGroup, type: :service do + describe '.call' do + it "returns occupations from the group ordered by started_at" do + group = create(:group) + older_occupation = create(:occupation, :finished, group:, started_at: 5.days.ago, ended_at: 4.days.ago) + newer_occupation = create(:occupation, group:, started_at: 1.day.ago) + create(:occupation) + + result = described_class.call(group:, page: 1, per_page: 10) + + expect(result.records).to eq([ newer_occupation, older_occupation ]) + end + + it "returns paginated results" do + group = create(:group) + create_list(:occupation, 25, :finished, group:) + + result = described_class.call(group:, page: 2, per_page: 10) + + expect(result.records.size).to eq(10) + expect(result.total_items).to eq(25) + expect(result.total_pages).to eq(3) + end + end +end diff --git a/spec/services/occupations/history_by_terrain_spec.rb b/spec/services/occupations/history_by_terrain_spec.rb new file mode 100644 index 0000000..3b0a9d4 --- /dev/null +++ b/spec/services/occupations/history_by_terrain_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Occupations::HistoryByTerrain, type: :service do + describe ".call" do + it "returns occupations from the terrain ordered by started_at" do + terrain = create(:terrain) + older_occupation = create(:occupation, :finished, terrain:, started_at: 5.days.ago, ended_at: 4.days.ago) + newer_occupation = create(:occupation, terrain:, started_at: 1.day.ago) + create(:occupation) + + result = described_class.call(terrain:, page: 1, per_page: 10) + + expect(result.records).to eq([ newer_occupation, older_occupation ]) + end + + it "returns paginated results" do + terrain = create(:terrain) + create_list(:occupation, 25, :finished, terrain:) + + result = described_class.call(terrain:, page: 2, per_page: 10) + + expect(result.records.size).to eq(10) + expect(result.total_items).to eq(25) + expect(result.total_pages).to eq(3) + end + end +end diff --git a/spec/services/occupations/move_spec.rb b/spec/services/occupations/move_spec.rb new file mode 100644 index 0000000..9b3b36a --- /dev/null +++ b/spec/services/occupations/move_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe Occupations::Move, type: :service do + describe '.call' do + let (:group) { create(:group) } + + it "moves the occupation without a current occupation to a terrain" do + destination = create(:terrain) + occupation = described_class.call(group:, destination:) + expect(occupation).to be_persisted + expect(occupation).to have_attributes(group:, terrain: destination, ended_at: nil) + expect(destination.reload).to be_occupied + end + + it "moves a group from one terrain to another" do + origin = create(:terrain, :occupied, rest_days: 10) + destination = create(:terrain) + previous_occupation = create(:occupation, group:, terrain: origin) + occupation = described_class.call(group:, destination:) + expect(previous_occupation.reload).to be_finished + expect(origin.reload).to be_resting + expect(occupation).to be_active + expect(occupation.terrain).to eq(destination) + expect(destination.reload).to be_occupied + end + + it "makes the origin terrain when it does not require rest" do + origin = create(:terrain, :occupied, rest_days: 0) + destination = create(:terrain) + create(:occupation, group:, terrain: origin) + described_class.call(group:, destination:) + expect(origin.reload).to be_available + end + + it "allows movement to a resting terrain" do + destination = create(:terrain, :resting) + occupation = described_class.call(group:, destination:) + expect(occupation.terrain).to eq(destination) + expect(destination.reload).to be_occupied + end + + it "does not change the current occupation when the movement is invalid" do + origin = create(:terrain, :occupied, rest_days: 10) + destination = create(:terrain, :occupied) + current_occupation = create(:occupation, group:, terrain: origin) + expect { + described_class.call(group:, destination:) + }.to raise_error(Occupations::MovementError) + expect(current_occupation.reload).to be_active + expect(origin.reload).to be_occupied + end + end +end diff --git a/spec/services/occupations/movement_policy_spec.rb b/spec/services/occupations/movement_policy_spec.rb new file mode 100644 index 0000000..bb791d7 --- /dev/null +++ b/spec/services/occupations/movement_policy_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe Occupations::MovementPolicy, type: :service do + describe '.validate!' do + let (:group) { create(:group) } + let (:destination) { create(:terrain) } + + it "allows movement to an available terrain" do + expect { + described_class.validate!(group:, current_occupation: nil, destination:) + }.not_to raise_error + end + + it "allows movement to a resting terrain" do + resting_terrain = create(:terrain, :resting) + expect { + described_class.validate!(group:, current_occupation: nil, destination: resting_terrain) + }.not_to raise_error + end + + it "does not allow movement to an occupied terrain" do + occupied_terrain = create(:terrain, :occupied) + expect { + described_class.validate!(group:, current_occupation: nil, destination: occupied_terrain) + }.to raise_error(Occupations::MovementError) + end + + it "does not allow movement for an inactive group" do + group.soft_delete! + expect { + described_class.validate!(group:, current_occupation: nil, destination:) + }.to raise_error(Occupations::MovementError) + end + + it "does not allow movement to an inactive terrain" do + destination.soft_delete! + expect { + described_class.validate!(group:, current_occupation: nil, destination:) + }.to raise_error(Occupations::MovementError) + end + + it "does not allow movement to the same terrain" do + current_occupation = create(:occupation, group:, terrain: destination) + expect { + described_class.validate!(group:, current_occupation:, destination:) + }.to raise_error(Occupations::MovementError) + end + end +end diff --git a/spec/services/occupations/open_spec.rb b/spec/services/occupations/open_spec.rb new file mode 100644 index 0000000..42779e0 --- /dev/null +++ b/spec/services/occupations/open_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Occupations::Open, type: :service do + describe '.call' do + let(:group) { create(:group) } + let(:terrain) { create(:terrain) } + + it 'creates an occupation' do + expect { + described_class.call(group:, terrain:) + }.to change(Occupation, :count).by(1) + end + + it "returns the created occupation" do + occupation = described_class.call(group:, terrain:) + expect(occupation).to be_persisted + expect(occupation).to have_attributes(group:, terrain:, ended_at: nil) + expect(occupation.started_at).to be_present + end + end +end diff --git a/spec/services/terrains/delete_spec.rb b/spec/services/terrains/deactivate_spec.rb similarity index 62% rename from spec/services/terrains/delete_spec.rb rename to spec/services/terrains/deactivate_spec.rb index 7926c3d..2896cef 100644 --- a/spec/services/terrains/delete_spec.rb +++ b/spec/services/terrains/deactivate_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' -RSpec.describe Terrains::Delete, type: :service do +RSpec.describe Terrains::Deactivate, type: :service do describe ".call" do - it "soft deletes the terrain" do + it "deactivates an available terrain" do terrain = create(:terrain) result = described_class.call(id: terrain.id) expect(result.deleted?).to be true @@ -14,6 +14,21 @@ expect { described_class.call(id: terrain.id) }.not_to change(Terrain, :count) end + it "deactivates a resting terrain" do + terrain = create(:terrain, :resting) + result = described_class.call(id: terrain.id) + expect(result.reload).to be_deleted + end + + it "does not deactivate a terrain with an active occupation" do + terrain = create(:terrain, :occupied) + create(:occupation, terrain:) + expect { + described_class.call(id: terrain.id) + }.to raise_error(Occupations::DeactivationError) + expect(terrain.reload).not_to be_deleted + end + it "removes the terrain from the active scope" do terrain = create(:terrain) described_class.call(id: terrain.id) diff --git a/spec/services/terrains/occupy_spec.rb b/spec/services/terrains/occupy_spec.rb new file mode 100644 index 0000000..f0a4fc0 --- /dev/null +++ b/spec/services/terrains/occupy_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Terrains::Occupy, type: :service do + describe '.call' do + it "marks the terrain as occupied" do + terrain = create(:terrain) + result = described_class.call(terrain:) + expect(result).to be_occupied + expect(terrain.reload).to be_occupied + end + end +end diff --git a/spec/services/terrains/release_resting_spec.rb b/spec/services/terrains/release_resting_spec.rb new file mode 100644 index 0000000..f987a6d --- /dev/null +++ b/spec/services/terrains/release_resting_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe Terrains::ReleaseResting, type: :service do + describe '.call' do + it "releases terrains that have completed their resting period" do + terrain = create(:terrain, :resting, rest_days: 5) + create(:occupation, :finished, terrain:, started_at: 10.days.ago, ended_at: 6.days.ago) + described_class.call + expect(terrain.reload).to be_available + end + + it "keeps terrains resting when the rest period is not completed" do + terrain = create(:terrain, :resting, rest_days: 10) + create(:occupation, :finished, terrain:, started_at: 5.days.ago, ended_at: 3.days.ago) + described_class.call + expect(terrain.reload).to be_resting + end + end +end diff --git a/spec/services/terrains/release_spec.rb b/spec/services/terrains/release_spec.rb new file mode 100644 index 0000000..c12b0b2 --- /dev/null +++ b/spec/services/terrains/release_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Terrains::Release, type: :service do + describe '.call' do + it "marks the terrain as available" do + terrain = create(:terrain, :resting) + result = described_class.call(terrain:) + expect(result).to be_available + expect(terrain.reload).to be_available + end + end +end diff --git a/spec/services/terrains/start_rest_spec.rb b/spec/services/terrains/start_rest_spec.rb new file mode 100644 index 0000000..f2a2c7a --- /dev/null +++ b/spec/services/terrains/start_rest_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe Terrains::StartRest, type: :service do + describe '.call' do + it "marks the terrain as resting" do + terrain = create(:terrain, :occupied, rest_days: 15) + result = described_class.call(terrain:) + expect(result).to be_resting + expect(terrain.reload).to be_resting + end + + it "releases the terrain immediately if rest_days is 0" do + terrain = create(:terrain, :occupied, rest_days: 0) + result = described_class.call(terrain:) + expect(result).to be_available + expect(terrain.reload).to be_available + end + end +end