mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Add implementation plan and complete phase 1
This commit is contained in:
parent
4287fee93d
commit
0d02f08199
17 changed files with 2430 additions and 1 deletions
1650
FAMILY_PLAN.md
Normal file
1650
FAMILY_PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
16
app/models/family.rb
Normal file
16
app/models/family.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family < ApplicationRecord
|
||||
has_many :family_memberships, dependent: :destroy
|
||||
has_many :members, through: :family_memberships, source: :user
|
||||
has_many :family_invitations, dependent: :destroy
|
||||
belongs_to :creator, class_name: 'User'
|
||||
|
||||
validates :name, presence: true, length: { maximum: 50 }
|
||||
|
||||
MAX_MEMBERS = 5
|
||||
|
||||
def can_add_members?
|
||||
members.count < MAX_MEMBERS
|
||||
end
|
||||
end
|
||||
37
app/models/family_invitation.rb
Normal file
37
app/models/family_invitation.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyInvitation < ApplicationRecord
|
||||
EXPIRY_DAYS = 7
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :invited_by, class_name: 'User'
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :token, presence: true, uniqueness: true
|
||||
validates :expires_at, presence: true
|
||||
validates :status, presence: true
|
||||
|
||||
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
|
||||
|
||||
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
|
||||
|
||||
before_validation :generate_token, :set_expiry, on: :create
|
||||
|
||||
def expired?
|
||||
expires_at < Time.current
|
||||
end
|
||||
|
||||
def can_be_accepted?
|
||||
pending? && !expired?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
||||
end
|
||||
end
|
||||
11
app/models/family_membership.rb
Normal file
11
app/models/family_membership.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyMembership < ApplicationRecord
|
||||
belongs_to :family
|
||||
belongs_to :user
|
||||
|
||||
validates :user_id, presence: true, uniqueness: true # One family per user
|
||||
validates :role, presence: true
|
||||
|
||||
enum :role, { owner: 0, member: 1 }
|
||||
end
|
||||
|
|
@ -15,12 +15,21 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
|
||||
# Family associations
|
||||
has_one :family_membership, dependent: :destroy
|
||||
has_one :family, through: :family_membership
|
||||
has_many :created_families, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
|
||||
has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id',
|
||||
inverse_of: :invited_by, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
|
||||
|
||||
before_save :sanitize_input
|
||||
|
||||
before_destroy :check_family_ownership
|
||||
|
||||
validates :email, presence: true
|
||||
validates :reset_password_token, uniqueness: true, allow_nil: true
|
||||
|
||||
|
|
@ -162,6 +171,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
||||
end
|
||||
|
||||
def check_family_ownership
|
||||
return if can_delete_account?
|
||||
|
||||
errors.add(:base, 'Cannot delete account while being a family owner with other members')
|
||||
raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members'
|
||||
end
|
||||
|
||||
def start_trial
|
||||
update(status: :trial, active_until: 7.days.from_now)
|
||||
schedule_welcome_emails
|
||||
|
|
@ -181,4 +197,42 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
|
||||
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
# Family-related methods
|
||||
def in_family?
|
||||
family_membership.present?
|
||||
end
|
||||
|
||||
def family_owner?
|
||||
family_membership&.owner? == true
|
||||
end
|
||||
|
||||
def can_delete_account?
|
||||
return true unless family_owner?
|
||||
return true unless family
|
||||
|
||||
family.members.count <= 1
|
||||
end
|
||||
|
||||
def family_sharing_enabled?
|
||||
in_family?
|
||||
end
|
||||
|
||||
def latest_location_for_family
|
||||
return nil unless family_sharing_enabled?
|
||||
|
||||
latest_point = points.order(timestamp: :desc).first
|
||||
return nil unless latest_point
|
||||
|
||||
{
|
||||
user_id: id,
|
||||
email: email,
|
||||
latitude: latest_point.latitude,
|
||||
longitude: latest_point.longitude,
|
||||
timestamp: latest_point.timestamp,
|
||||
updated_at: Time.at(latest_point.timestamp)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
14
db/migrate/20250926220114_create_families.rb
Normal file
14
db/migrate/20250926220114_create_families.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFamilies < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :families, id: :uuid do |t|
|
||||
t.string :name, null: false, limit: 50
|
||||
t.bigint :creator_id, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :families, :users, column: :creator_id, validate: false
|
||||
add_index :families, :creator_id
|
||||
end
|
||||
end
|
||||
18
db/migrate/20250926220135_create_family_memberships.rb
Normal file
18
db/migrate/20250926220135_create_family_memberships.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFamilyMemberships < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :family_memberships, id: :uuid do |t|
|
||||
t.uuid :family_id, null: false
|
||||
t.bigint :user_id, null: false
|
||||
t.integer :role, null: false, default: 1 # member
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :family_memberships, :families, validate: false
|
||||
add_foreign_key :family_memberships, :users, validate: false
|
||||
add_index :family_memberships, :family_id
|
||||
add_index :family_memberships, :user_id, unique: true # One family per user
|
||||
add_index :family_memberships, %i[family_id role]
|
||||
end
|
||||
end
|
||||
23
db/migrate/20250926220158_create_family_invitations.rb
Normal file
23
db/migrate/20250926220158_create_family_invitations.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFamilyInvitations < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :family_invitations, id: :uuid do |t|
|
||||
t.uuid :family_id, null: false
|
||||
t.string :email, null: false
|
||||
t.string :token, null: false
|
||||
t.datetime :expires_at, null: false
|
||||
t.bigint :invited_by_id, null: false
|
||||
t.integer :status, null: false, default: 0 # pending
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_foreign_key :family_invitations, :families, validate: false
|
||||
add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false
|
||||
add_index :family_invitations, :family_id
|
||||
add_index :family_invitations, :email
|
||||
add_index :family_invitations, :token, unique: true
|
||||
add_index :family_invitations, :status
|
||||
add_index :family_invitations, :expires_at
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
validate_foreign_key :families, :users
|
||||
validate_foreign_key :family_memberships, :families
|
||||
validate_foreign_key :family_memberships, :users
|
||||
validate_foreign_key :family_invitations, :families
|
||||
validate_foreign_key :family_invitations, :users
|
||||
end
|
||||
end
|
||||
43
db/schema.rb
generated
43
db/schema.rb
generated
|
|
@ -10,9 +10,10 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_26_220345) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "postgis"
|
||||
|
||||
create_table "action_text_rich_texts", force: :cascade do |t|
|
||||
|
|
@ -96,6 +97,41 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
|
|||
t.index ["user_id"], name: "index_exports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name", limit: 50, null: false
|
||||
t.bigint "creator_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["creator_id"], name: "index_families_on_creator_id"
|
||||
end
|
||||
|
||||
create_table "family_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "email", null: false
|
||||
t.string "token", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.bigint "invited_by_id", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["email"], name: "index_family_invitations_on_email"
|
||||
t.index ["expires_at"], name: "index_family_invitations_on_expires_at"
|
||||
t.index ["family_id"], name: "index_family_invitations_on_family_id"
|
||||
t.index ["status"], name: "index_family_invitations_on_status"
|
||||
t.index ["token"], name: "index_family_invitations_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "family_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.integer "role", default: 1, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role"
|
||||
t.index ["family_id"], name: "index_family_memberships_on_family_id"
|
||||
t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true
|
||||
end
|
||||
|
||||
create_table "imports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
|
|
@ -307,6 +343,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_18_215512) do
|
|||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "families", "users", column: "creator_id"
|
||||
add_foreign_key "family_invitations", "families"
|
||||
add_foreign_key "family_invitations", "users", column: "invited_by_id"
|
||||
add_foreign_key "family_memberships", "families"
|
||||
add_foreign_key "family_memberships", "users"
|
||||
add_foreign_key "notifications", "users"
|
||||
add_foreign_key "place_visits", "places"
|
||||
add_foreign_key "place_visits", "visits"
|
||||
|
|
|
|||
8
spec/factories/families.rb
Normal file
8
spec/factories/families.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :family do
|
||||
sequence(:name) { |n| "Test Family #{n}" }
|
||||
association :creator, factory: :user
|
||||
end
|
||||
end
|
||||
29
spec/factories/family_invitations.rb
Normal file
29
spec/factories/family_invitations.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :family_invitation do
|
||||
association :family
|
||||
association :invited_by, factory: :user
|
||||
sequence(:email) { |n| "invite#{n}@example.com" }
|
||||
token { SecureRandom.urlsafe_base64(32) }
|
||||
expires_at { 7.days.from_now }
|
||||
status { :pending }
|
||||
|
||||
trait :accepted do
|
||||
status { :accepted }
|
||||
end
|
||||
|
||||
trait :expired do
|
||||
status { :expired }
|
||||
expires_at { 1.day.ago }
|
||||
end
|
||||
|
||||
trait :cancelled do
|
||||
status { :cancelled }
|
||||
end
|
||||
|
||||
trait :with_expired_date do
|
||||
expires_at { 1.day.ago }
|
||||
end
|
||||
end
|
||||
end
|
||||
13
spec/factories/family_memberships.rb
Normal file
13
spec/factories/family_memberships.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :family_membership do
|
||||
association :family
|
||||
association :user
|
||||
role { :member }
|
||||
|
||||
trait :owner do
|
||||
role { :owner }
|
||||
end
|
||||
end
|
||||
end
|
||||
177
spec/models/family_invitation_spec.rb
Normal file
177
spec/models/family_invitation_spec.rb
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FamilyInvitation, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:family) }
|
||||
it { is_expected.to belong_to(:invited_by).class_name('User') }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { build(:family_invitation) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:email) }
|
||||
it { is_expected.to allow_value('test@example.com').for(:email) }
|
||||
it { is_expected.not_to allow_value('invalid-email').for(:email) }
|
||||
it { is_expected.to validate_uniqueness_of(:token) }
|
||||
it { is_expected.to validate_presence_of(:status) }
|
||||
|
||||
it 'validates token presence after creation' do
|
||||
invitation = build(:family_invitation, token: nil)
|
||||
invitation.save
|
||||
expect(invitation.token).to be_present
|
||||
end
|
||||
|
||||
it 'validates expires_at presence after creation' do
|
||||
invitation = build(:family_invitation, expires_at: nil)
|
||||
invitation.save
|
||||
expect(invitation.expires_at).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:status).with_values(pending: 0, accepted: 1, expired: 2, cancelled: 3) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:family) { create(:family) }
|
||||
let(:pending_invitation) do
|
||||
create(:family_invitation, family: family, status: :pending, expires_at: 1.day.from_now)
|
||||
end
|
||||
let(:expired_invitation) { create(:family_invitation, family: family, status: :pending, expires_at: 1.day.ago) }
|
||||
let(:accepted_invitation) { create(:family_invitation, :accepted, family: family) }
|
||||
|
||||
describe '.active' do
|
||||
it 'returns only pending and non-expired invitations' do
|
||||
expect(FamilyInvitation.active).to include(pending_invitation)
|
||||
expect(FamilyInvitation.active).not_to include(expired_invitation)
|
||||
expect(FamilyInvitation.active).not_to include(accepted_invitation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
describe 'before_validation on create' do
|
||||
let(:invitation) { build(:family_invitation, token: nil, expires_at: nil) }
|
||||
|
||||
it 'generates a token' do
|
||||
invitation.save
|
||||
expect(invitation.token).to be_present
|
||||
expect(invitation.token.length).to be > 20
|
||||
end
|
||||
|
||||
it 'sets expiry date' do
|
||||
invitation.save
|
||||
expect(invitation.expires_at).to be_within(1.minute).of(FamilyInvitation::EXPIRY_DAYS.days.from_now)
|
||||
end
|
||||
|
||||
it 'does not override existing token' do
|
||||
custom_token = 'custom-token'
|
||||
invitation.token = custom_token
|
||||
invitation.save
|
||||
expect(invitation.token).to eq(custom_token)
|
||||
end
|
||||
|
||||
it 'does not override existing expiry date' do
|
||||
custom_expiry = 3.days.from_now
|
||||
invitation.expires_at = custom_expiry
|
||||
invitation.save
|
||||
expect(invitation.expires_at).to be_within(1.second).of(custom_expiry)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expired?' do
|
||||
context 'when expires_at is in the future' do
|
||||
let(:invitation) { create(:family_invitation, expires_at: 1.day.from_now) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(invitation.expired?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expires_at is in the past' do
|
||||
let(:invitation) { create(:family_invitation, expires_at: 1.day.ago) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(invitation.expired?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_be_accepted?' do
|
||||
context 'when invitation is pending and not expired' do
|
||||
let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.from_now) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(invitation.can_be_accepted?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invitation is pending but expired' do
|
||||
let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.ago) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(invitation.can_be_accepted?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invitation is accepted' do
|
||||
let(:invitation) { create(:family_invitation, :accepted, expires_at: 1.day.from_now) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(invitation.can_be_accepted?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invitation is cancelled' do
|
||||
let(:invitation) { create(:family_invitation, :cancelled, expires_at: 1.day.from_now) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(invitation.can_be_accepted?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
it 'defines EXPIRY_DAYS' do
|
||||
expect(FamilyInvitation::EXPIRY_DAYS).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'token uniqueness' do
|
||||
let(:family) { create(:family) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'ensures each invitation has a unique token' do
|
||||
invitation1 = create(:family_invitation, family: family, invited_by: user)
|
||||
invitation2 = create(:family_invitation, family: family, invited_by: user)
|
||||
|
||||
expect(invitation1.token).not_to eq(invitation2.token)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'email format validation' do
|
||||
let(:invitation) { build(:family_invitation) }
|
||||
|
||||
it 'accepts valid email formats' do
|
||||
valid_emails = ['test@example.com', 'user.name@domain.co.uk', 'user+tag@example.org']
|
||||
|
||||
valid_emails.each do |email|
|
||||
invitation.email = email
|
||||
expect(invitation).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects invalid email formats' do
|
||||
invalid_emails = ['invalid-email', '@example.com', 'user@', 'user space@example.com']
|
||||
|
||||
invalid_emails.each do |email|
|
||||
invitation.email = email
|
||||
expect(invitation).not_to be_valid
|
||||
expect(invitation.errors[:email]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
68
spec/models/family_membership_spec.rb
Normal file
68
spec/models/family_membership_spec.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FamilyMembership, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:family) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { build(:family_membership) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:user_id) }
|
||||
it { is_expected.to validate_uniqueness_of(:user_id) }
|
||||
it { is_expected.to validate_presence_of(:role) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:role).with_values(owner: 0, member: 1) }
|
||||
end
|
||||
|
||||
describe 'one family per user constraint' do
|
||||
let(:user) { create(:user) }
|
||||
let(:family1) { create(:family) }
|
||||
let(:family2) { create(:family) }
|
||||
|
||||
it 'allows a user to be in one family' do
|
||||
membership1 = build(:family_membership, family: family1, user: user)
|
||||
expect(membership1).to be_valid
|
||||
end
|
||||
|
||||
it 'prevents a user from being in multiple families' do
|
||||
create(:family_membership, family: family1, user: user)
|
||||
membership2 = build(:family_membership, family: family2, user: user)
|
||||
|
||||
expect(membership2).not_to be_valid
|
||||
expect(membership2.errors[:user_id]).to include('has already been taken')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'role assignment' do
|
||||
let(:family) { create(:family) }
|
||||
|
||||
context 'when created as owner' do
|
||||
let(:membership) { create(:family_membership, :owner, family: family) }
|
||||
|
||||
it 'can be created' do
|
||||
expect(membership.role).to eq('owner')
|
||||
expect(membership.owner?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when created as member' do
|
||||
let(:membership) { create(:family_membership, family: family, role: :member) }
|
||||
|
||||
it 'can be created' do
|
||||
expect(membership.role).to eq('member')
|
||||
expect(membership.member?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'defaults to member role' do
|
||||
membership = create(:family_membership, family: family)
|
||||
expect(membership.role).to eq('member')
|
||||
end
|
||||
end
|
||||
end
|
||||
125
spec/models/family_spec.rb
Normal file
125
spec/models/family_spec.rb
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Family, type: :model do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to have_many(:family_memberships).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:members).through(:family_memberships).source(:user) }
|
||||
it { is_expected.to have_many(:family_invitations).dependent(:destroy) }
|
||||
it { is_expected.to belong_to(:creator).class_name('User') }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(50) }
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
it 'defines MAX_MEMBERS' do
|
||||
expect(Family::MAX_MEMBERS).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_add_members?' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
context 'when family has fewer than max members' do
|
||||
before do
|
||||
create(:family_membership, family: family, user: user, role: :owner)
|
||||
create_list(:family_membership, 3, family: family, role: :member)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(family.can_add_members?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when family has max members' do
|
||||
before do
|
||||
create(:family_membership, family: family, user: user, role: :owner)
|
||||
create_list(:family_membership, 4, family: family, role: :member)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(family.can_add_members?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when family has no members' do
|
||||
it 'returns true' do
|
||||
expect(family.can_add_members?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'family creation' do
|
||||
let(:family) { Family.new(name: 'Test Family', creator: user) }
|
||||
|
||||
it 'can be created with valid attributes' do
|
||||
expect(family).to be_valid
|
||||
end
|
||||
|
||||
it 'requires a name' do
|
||||
family.name = nil
|
||||
|
||||
expect(family).not_to be_valid
|
||||
expect(family.errors[:name]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires a creator' do
|
||||
family.creator = nil
|
||||
|
||||
expect(family).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects names longer than 50 characters' do
|
||||
long_name = 'a' * 51
|
||||
family.name = long_name
|
||||
|
||||
expect(family).not_to be_valid
|
||||
expect(family.errors[:name]).to include('is too long (maximum is 50 characters)')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'members association' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
let(:member1) { create(:user) }
|
||||
let(:member2) { create(:user) }
|
||||
|
||||
before do
|
||||
create(:family_membership, family: family, user: user, role: :owner)
|
||||
create(:family_membership, family: family, user: member1, role: :member)
|
||||
create(:family_membership, family: family, user: member2, role: :member)
|
||||
end
|
||||
|
||||
it 'includes all family members' do
|
||||
expect(family.members).to include(user, member1, member2)
|
||||
expect(family.members.count).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'family invitations association' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
it 'destroys associated invitations when family is destroyed' do
|
||||
invitation = create(:family_invitation, family: family, invited_by: user)
|
||||
|
||||
expect { family.destroy }.to change(FamilyInvitation, :count).by(-1)
|
||||
expect(FamilyInvitation.find_by(id: invitation.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'family memberships association' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
it 'destroys associated memberships when family is destroyed' do
|
||||
membership = create(:family_membership, family: family, user: user, role: :owner)
|
||||
|
||||
expect { family.destroy }.to change(FamilyMembership, :count).by(-1)
|
||||
expect(FamilyMembership.find_by(id: membership.id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
136
spec/models/user_family_spec.rb
Normal file
136
spec/models/user_family_spec.rb
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User, 'family methods', type: :model do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'family associations' do
|
||||
it { is_expected.to have_one(:family_membership).dependent(:destroy) }
|
||||
it { is_expected.to have_one(:family).through(:family_membership) }
|
||||
it {
|
||||
is_expected.to have_many(:created_families).class_name('Family').with_foreign_key('creator_id').dependent(:destroy)
|
||||
}
|
||||
it {
|
||||
is_expected.to have_many(:sent_family_invitations).class_name('FamilyInvitation').with_foreign_key('invited_by_id').dependent(:destroy)
|
||||
}
|
||||
end
|
||||
|
||||
describe '#in_family?' do
|
||||
context 'when user has no family membership' do
|
||||
it 'returns false' do
|
||||
expect(user.in_family?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has family membership' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
before do
|
||||
create(:family_membership, user: user, family: family)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(user.in_family?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#family_owner?' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
context 'when user is family owner' do
|
||||
before do
|
||||
create(:family_membership, user: user, family: family, role: :owner)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(user.family_owner?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is family member' do
|
||||
before do
|
||||
create(:family_membership, user: user, family: family, role: :member)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(user.family_owner?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no family membership' do
|
||||
it 'returns false' do
|
||||
expect(user.family_owner?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_delete_account?' do
|
||||
context 'when user is not a family owner' do
|
||||
it 'returns true' do
|
||||
expect(user.can_delete_account?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is family owner with only themselves as member' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
before do
|
||||
create(:family_membership, user: user, family: family, role: :owner)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(user.can_delete_account?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is family owner with other members' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
let(:other_user) { create(:user) }
|
||||
|
||||
before do
|
||||
create(:family_membership, user: user, family: family, role: :owner)
|
||||
create(:family_membership, user: other_user, family: family, role: :member)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(user.can_delete_account?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'dependent destroy behavior' do
|
||||
let(:family) { create(:family, creator: user) }
|
||||
|
||||
context 'when user has created families' do
|
||||
it 'prevents deletion when family has members' do
|
||||
other_user = create(:user)
|
||||
create(:family_membership, user: user, family: family, role: :owner)
|
||||
create(:family_membership, user: other_user, family: family, role: :member)
|
||||
|
||||
expect(user.can_delete_account?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has sent invitations' do
|
||||
before do
|
||||
create(:family_invitation, family: family, invited_by: user)
|
||||
end
|
||||
|
||||
it 'destroys associated invitations when user is destroyed' do
|
||||
expect { user.destroy }.to change(FamilyInvitation, :count).by(-1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has family membership' do
|
||||
before do
|
||||
create(:family_membership, user: user, family: family)
|
||||
end
|
||||
|
||||
it 'destroys associated membership when user is destroyed' do
|
||||
expect { user.destroy }.to change(FamilyMembership, :count).by(-1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue