diff --git a/.gitignore b/.gitignore
index 7094038..5222302 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,19 @@ yarn-debug.log*
*~
# Claude
-.claude/
+# Personal Claude Code settings
+.claude/settings.local.json
+.claude/CLAUDE.local.md
+
+# Sensitive data and generated files within skills
+.claude/skills/*/.venv/
+.claude/skills/*/venv/
+.claude/skills/*/node_modules/
+.claude/skills/*/data/
+.claude/skills/*/auth_info.json
+.claude/skills/*/__pycache__/
+
+# Logs and temporary files
+.claude/logs/
+.claude/tmp/
+.claude/*.log
diff --git a/CLAUDE.md b/CLAUDE.md
index 8b7a017..97606d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,10 +74,19 @@ bin/rails dbconsole # Database console
- Monitor at `/madmin/sidekiq` in development
### Testing Strategy
-- Minitest for all tests (models, controllers, integration)
+- Minitest for all tests (models, controllers, integration, policies)
- WebMock for stubbing external HTTP requests
-- Parallel test execution supported
-- API integration tests for all endpoints
+- Parallel test execution supported (10 workers by default)
+- Comprehensive test coverage across all layers:
+ - **Model tests**: test/models/ - Validations, associations, callbacks, state machines
+ - **Policy tests**: test/policies/ - Authorization rules for all user roles
+ - **Controller tests**: test/controllers/ - API endpoints, authentication, authorization
+ - **Integration tests**: test/integration/ - End-to-end user flows
+- Test helpers:
+ - `json_response` for parsing JSON API responses
+ - `create_new_auth_token` for generating auth headers (Devise Token Auth)
+ - Fixtures in test/fixtures/ and seed data in db/fixtures/test/
+- Run tests: `bin/rails test` (205 tests, 402 assertions)
### Development Server Configuration
- Server binds to specific IP: `192.168.1.21:3000` (not localhost)
@@ -98,14 +107,55 @@ bin/rails dbconsole # Database console
- Web server: `bin/render-start.sh`
- Background workers: `bin/render-start-sidekiq.sh`
+## Code Quality Checks Before Committing
+
+**IMPORTANT**: Always run these checks and fix all errors before committing code:
+
+### 1. Lint Errors (RuboCop)
+```bash
+bin/rubocop
+```
+- Fix all RuboCop offenses before committing
+- Run `bin/rubocop -a` to auto-correct safe offenses
+- Review and manually fix remaining issues
+
+### 2. Security Scan (Brakeman)
+```bash
+bin/brakeman
+```
+- Fix all security vulnerabilities before committing
+- Review warnings and address potential security issues
+- Never commit code with security vulnerabilities
+
+### 3. Run Tests
+```bash
+bin/rails test
+```
+- Ensure all tests pass before committing
+- Add tests for new features or bug fixes
+
+### Pre-Commit Checklist
+- [ ] `bin/rubocop` - No lint errors
+- [ ] `bin/brakeman` - No security issues
+- [ ] `bin/rails test` - All tests passing
+- [ ] Code reviewed for quality and security
+
## Common Development Tasks
### Creating New API Endpoints
1. Add route in `config/routes.rb` under appropriate namespace
2. Create controller inheriting from `Api::V1::BaseController`
-3. Add Pundit policy in `app/policies/`
+3. Add Pundit policy in `app/policies/` with authorization rules
4. Create serializer in `app/serializers/`
-5. Write integration tests in `test/integration/`
+5. Write controller tests in `test/controllers/` testing all actions and edge cases
+
+### Writing Tests
+- **Model tests**: Test validations, associations, callbacks, scopes, and business logic
+- **Policy tests**: Test authorization for all roles (admin, managers, members, guest)
+- **Controller tests**: Test CRUD operations, authentication requirements, authorization checks
+- Use `ActsAsTenant.with_tenant(@account)` when testing multi-tenant models
+- Fixtures are loaded automatically from test/fixtures/*.yml
+- Test data seeds loaded from db/fixtures/test/*.rb in setup hook
### Working with Multi-tenancy
- All models should include `acts_as_tenant :account`
diff --git a/Gemfile b/Gemfile
index a9da2d9..8b3de92 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,7 +4,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby file: ".ruby-version"
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
-gem "rails", "7.1.5.1"
+gem "rails", "~> 7.2.3"
gem "propshaft", "~> 1.0"
@@ -78,6 +78,9 @@ group :development, :test do
gem "mailbin"
+ # Constrain minitest to 5.x for Rails 7.2 compatibility
+ gem "minitest", "~> 5.0"
+
# Optional debugging tools
# gem "byebug", platforms: [:mri, :mingw, :x64_mingw]
# gem "pry-rails"
diff --git a/Gemfile.lock b/Gemfile.lock
index 0544787..84b9f5b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,83 +13,79 @@ GEM
specs:
aasm (5.5.0)
concurrent-ruby (~> 1.0)
- actioncable (7.1.5.1)
- actionpack (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actioncable (7.2.3)
+ actionpack (= 7.2.3)
+ activesupport (= 7.2.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.1.5.1)
- actionpack (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
- mail (>= 2.7.1)
- net-imap
- net-pop
- net-smtp
- actionmailer (7.1.5.1)
- actionpack (= 7.1.5.1)
- actionview (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activesupport (= 7.1.5.1)
- mail (~> 2.5, >= 2.5.4)
- net-imap
- net-pop
- net-smtp
+ actionmailbox (7.2.3)
+ actionpack (= 7.2.3)
+ activejob (= 7.2.3)
+ activerecord (= 7.2.3)
+ activestorage (= 7.2.3)
+ activesupport (= 7.2.3)
+ mail (>= 2.8.0)
+ actionmailer (7.2.3)
+ actionpack (= 7.2.3)
+ actionview (= 7.2.3)
+ activejob (= 7.2.3)
+ activesupport (= 7.2.3)
+ mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (7.1.5.1)
- actionview (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionpack (7.2.3)
+ actionview (= 7.2.3)
+ activesupport (= 7.2.3)
+ cgi
nokogiri (>= 1.8.5)
racc
- rack (>= 2.2.4)
+ rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- actiontext (7.1.5.1)
- actionpack (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ useragent (~> 0.16)
+ actiontext (7.2.3)
+ actionpack (= 7.2.3)
+ activerecord (= 7.2.3)
+ activestorage (= 7.2.3)
+ activesupport (= 7.2.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.1.5.1)
- activesupport (= 7.1.5.1)
+ actionview (7.2.3)
+ activesupport (= 7.2.3)
builder (~> 3.1)
+ cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (7.1.5.1)
- activesupport (= 7.1.5.1)
+ activejob (7.2.3)
+ activesupport (= 7.2.3)
globalid (>= 0.3.6)
- activemodel (7.1.5.1)
- activesupport (= 7.1.5.1)
- activerecord (7.1.5.1)
- activemodel (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ activemodel (7.2.3)
+ activesupport (= 7.2.3)
+ activerecord (7.2.3)
+ activemodel (= 7.2.3)
+ activesupport (= 7.2.3)
timeout (>= 0.4.0)
- activestorage (7.1.5.1)
- actionpack (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ activestorage (7.2.3)
+ actionpack (= 7.2.3)
+ activejob (= 7.2.3)
+ activerecord (= 7.2.3)
+ activesupport (= 7.2.3)
marcel (~> 1.0)
- activesupport (7.1.5.1)
+ activesupport (7.2.3)
base64
benchmark (>= 0.3)
bigdecimal
- concurrent-ruby (~> 1.0, >= 1.0.2)
+ concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
- mutex_m
securerandom (>= 0.3)
- tzinfo (~> 2.0)
+ tzinfo (~> 2.0, >= 2.0.5)
acts_as_tenant (1.0.1)
rails (>= 6.0)
addressable (2.8.7)
@@ -98,9 +94,9 @@ GEM
activerecord (>= 4.2)
activesupport
ast (2.4.2)
- base64 (0.2.0)
+ base64 (0.3.0)
bcrypt (3.1.20)
- benchmark (0.4.0)
+ benchmark (0.5.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -108,11 +104,11 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
- bigdecimal (3.1.9)
+ bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
- brakeman (7.0.2)
+ brakeman (8.0.4)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -124,14 +120,15 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
chronic (0.10.2)
- concurrent-ruby (1.3.5)
+ concurrent-ruby (1.3.6)
config (5.5.2)
deep_merge (~> 1.2, >= 1.2.1)
ostruct
- connection_pool (2.5.0)
+ connection_pool (3.0.2)
crack (1.0.0)
bigdecimal
rexml
@@ -139,7 +136,7 @@ GEM
cssbundling-rails (1.4.1)
railties (>= 6.0.0)
csv (3.3.2)
- date (3.4.1)
+ date (3.5.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -154,7 +151,8 @@ GEM
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 8.1)
- drb (2.2.1)
+ drb (2.2.3)
+ erb (6.0.1)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -169,10 +167,10 @@ GEM
ffi (1.17.1-x86-linux-gnu)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
- globalid (1.2.1)
+ globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.1.2)
- i18n (1.14.7)
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
@@ -185,8 +183,8 @@ GEM
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
- io-console (0.8.0)
- irb (1.15.1)
+ io-console (0.8.2)
+ irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@@ -199,11 +197,12 @@ GEM
jsonapi-serializer (2.2.0)
activesupport (>= 4.2)
language_server-protocol (3.17.0.4)
- logger (1.6.5)
- loofah (2.24.0)
+ logger (1.7.0)
+ loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.8.1)
+ mail (2.9.0)
+ logger
mini_mime (>= 0.1.1)
net-imap
net-pop
@@ -212,15 +211,14 @@ GEM
importmap-rails
rails (>= 7.1.0)
turbo-rails
- marcel (1.0.4)
+ marcel (1.1.0)
matrix (0.4.2)
mini_magick (4.13.2)
mini_mime (1.1.5)
- mini_portile2 (2.8.8)
- minitest (5.25.4)
+ mini_portile2 (2.8.9)
+ minitest (5.27.0)
msgpack (1.8.0)
- mutex_m (0.3.0)
- net-imap (0.5.5)
+ net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -229,19 +227,19 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
- nio4r (2.7.4)
- nokogiri (1.18.2)
+ nio4r (2.7.5)
+ nokogiri (1.19.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.18.2-aarch64-linux-gnu)
+ nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.2-arm-linux-gnu)
+ nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.2-arm64-darwin)
+ nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.18.2-x86_64-darwin)
+ nokogiri (1.19.0-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.18.2-x86_64-linux-gnu)
+ nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
ostruct (0.6.1)
@@ -255,7 +253,7 @@ GEM
ast (~> 2.4.1)
racc
pg (1.5.9)
- pp (0.6.2)
+ pp (0.6.3)
prettyprint
prettyprint (0.2.0)
propshaft (1.1.0)
@@ -263,7 +261,7 @@ GEM
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
- psych (5.2.3)
+ psych (5.3.1)
date
stringio
public_suffix (6.0.1)
@@ -272,57 +270,61 @@ GEM
pundit (2.4.0)
activesupport (>= 3.0.0)
racc (1.8.1)
- rack (3.1.9)
+ rack (3.2.4)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
- rack-session (2.1.0)
+ rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
- rackup (2.2.1)
+ rackup (2.3.1)
rack (>= 3)
- rails (7.1.5.1)
- actioncable (= 7.1.5.1)
- actionmailbox (= 7.1.5.1)
- actionmailer (= 7.1.5.1)
- actionpack (= 7.1.5.1)
- actiontext (= 7.1.5.1)
- actionview (= 7.1.5.1)
- activejob (= 7.1.5.1)
- activemodel (= 7.1.5.1)
- activerecord (= 7.1.5.1)
- activestorage (= 7.1.5.1)
- activesupport (= 7.1.5.1)
+ rails (7.2.3)
+ actioncable (= 7.2.3)
+ actionmailbox (= 7.2.3)
+ actionmailer (= 7.2.3)
+ actionpack (= 7.2.3)
+ actiontext (= 7.2.3)
+ actionview (= 7.2.3)
+ activejob (= 7.2.3)
+ activemodel (= 7.2.3)
+ activerecord (= 7.2.3)
+ activestorage (= 7.2.3)
+ activesupport (= 7.2.3)
bundler (>= 1.15.0)
- railties (= 7.1.5.1)
- rails-dom-testing (2.2.0)
+ railties (= 7.2.3)
+ rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (7.1.5.1)
- actionpack (= 7.1.5.1)
- activesupport (= 7.1.5.1)
- irb
+ railties (7.2.3)
+ actionpack (= 7.2.3)
+ activesupport (= 7.2.3)
+ cgi
+ irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
- rake (13.2.1)
- rdoc (6.12.0)
+ rake (13.3.1)
+ rdoc (7.0.3)
+ erb
psych (>= 4.0.0)
+ tsort
redis (5.3.0)
redis-client (>= 0.22.0)
redis-client (0.23.2)
connection_pool
regexp_parser (2.10.0)
- reline (0.6.0)
+ reline (0.6.3)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
@@ -380,9 +382,10 @@ GEM
smart_properties (1.17.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
- stringio (3.1.2)
- thor (1.3.2)
- timeout (0.4.3)
+ stringio (3.2.0)
+ thor (1.5.0)
+ timeout (0.6.0)
+ tsort (0.2.0)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
@@ -391,6 +394,7 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
+ useragent (0.16.11)
valid_email2 (7.0.0)
activemodel (>= 6.0)
mail (~> 2.5)
@@ -406,7 +410,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
- websocket-driver (0.7.7)
+ websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -414,7 +418,7 @@ GEM
chronic (>= 0.6.3)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.7.1)
+ zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
@@ -444,6 +448,7 @@ DEPENDENCIES
jsonapi-serializer
madmin!
mailbin
+ minitest (~> 5.0)
nokogiri (>= 1.12.5)
overcommit
pagy (~> 9.0)
@@ -453,7 +458,7 @@ DEPENDENCIES
pundit
rack-attack
rack-cors
- rails (= 7.1.5.1)
+ rails (~> 7.2.3)
redis (~> 5.1)
rubocop-rails-omakase
seed-fu (~> 2.3)
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
new file mode 100644
index 0000000..b876345
--- /dev/null
+++ b/config/brakeman.ignore
@@ -0,0 +1,29 @@
+{
+ "ignored_warnings": [
+ {
+ "warning_type": "Mass Assignment",
+ "warning_code": 105,
+ "fingerprint": "5be66927ab36c68816fde998b592c1581de478fca71f02be2af0c87e0c4f0196",
+ "check_name": "PermitAttributes",
+ "message": "Potentially dangerous key allowed for mass assignment",
+ "file": "app/controllers/api/v1/shopkeeper/accounts_shopkeepers_controller.rb",
+ "line": 57,
+ "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
+ "code": "params.require(:accounts_shopkeeper).permit(:admin, :senior_manager, :junior_manager, :senior_member, :junior_member, :guest)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "Api::V1::Shopkeeper::AccountsShopkeepersController",
+ "method": "accounts_shopkeeper_params"
+ },
+ "user_input": ":admin",
+ "confidence": "High",
+ "cwe_id": [
+ 915
+ ],
+ "note": "Intentional: role booleans are explicitly permitted for account admins to manage team member roles. Endpoint is protected by require_account_admin and require_non_personal_account! before_actions."
+ }
+ ],
+ "updated": "2026-03-01",
+ "brakeman_version": "8.0.4"
+}
diff --git a/config/initializers/new_framework_defaults_7_2.rb b/config/initializers/new_framework_defaults_7_2.rb
new file mode 100644
index 0000000..b549c4a
--- /dev/null
+++ b/config/initializers/new_framework_defaults_7_2.rb
@@ -0,0 +1,70 @@
+# Be sure to restart your server when you modify this file.
+#
+# This file eases your Rails 7.2 framework defaults upgrade.
+#
+# Uncomment each configuration one by one to switch to the new default.
+# Once your application is ready to run with all new defaults, you can remove
+# this file and set the `config.load_defaults` to `7.2`.
+#
+# Read the Guide for Upgrading Ruby on Rails for more info on each option.
+# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
+
+###
+# Controls whether Active Job's `#perform_later` and similar methods automatically defer
+# the job queuing to after the current Active Record transaction is committed.
+#
+# Example:
+# Topic.transaction do
+# topic = Topic.create(...)
+# NewTopicNotificationJob.perform_later(topic)
+# end
+#
+# In this example, if the configuration is set to `:never`, the job will
+# be enqueued immediately, even though the `Topic` hasn't been committed yet.
+# Because of this, if the job is picked up almost immediately, or if the
+# transaction doesn't succeed for some reason, the job will fail to find this
+# topic in the database.
+#
+# If `enqueue_after_transaction_commit` is set to `:default`, the queue adapter
+# will define the behaviour.
+#
+# Note: Active Job backends can disable this feature. This is generally done by
+# backends that use the same database as Active Record as a queue, hence they
+# don't need this feature.
+#++
+# Rails.application.config.active_job.enqueue_after_transaction_commit = :default
+
+###
+# Adds image/webp to the list of content types Active Storage considers as an image
+# Prevents automatic conversion to a fallback PNG, and assumes clients support WebP, as they support gif, jpeg, and png.
+# This is possible due to broad browser support for WebP, but older browsers and email clients may still not support
+# WebP. Requires imagemagick/libvips built with WebP support.
+#++
+# Rails.application.config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif image/webp]
+
+###
+# Enable validation of migration timestamps. When set, an ActiveRecord::InvalidMigrationTimestampError
+# will be raised if the timestamp prefix for a migration is more than a day ahead of the timestamp
+# associated with the current time. This is done to prevent forward-dating of migration files, which can
+# impact migration generation and other migration commands.
+#
+# Applications with existing timestamped migrations that do not adhere to the
+# expected format can disable validation by setting this config to `false`.
+#++
+# Rails.application.config.active_record.validate_migration_timestamps = true
+
+###
+# Controls whether the PostgresqlAdapter should decode dates automatically with manual queries.
+#
+# Example:
+# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.select_value("select '2024-01-01'::date") #=> Date
+#
+# This query used to return a `String`.
+#++
+# Rails.application.config.active_record.postgresql_adapter_decode_dates = true
+
+###
+# Enables YJIT as of Ruby 3.3, to bring sizeable performance improvements. If you are
+# deploying to a memory constrained environment you may want to set this to `false`.
+#++
+# Rails.application.config.yjit = true
diff --git a/db/migrate/20260306071846_add_service_name_to_active_storage_blobs.active_storage.rb b/db/migrate/20260306071846_add_service_name_to_active_storage_blobs.active_storage.rb
new file mode 100644
index 0000000..0267f12
--- /dev/null
+++ b/db/migrate/20260306071846_add_service_name_to_active_storage_blobs.active_storage.rb
@@ -0,0 +1,22 @@
+# This migration comes from active_storage (originally 20190112182829)
+class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0]
+ def up
+ return unless table_exists?(:active_storage_blobs)
+
+ unless column_exists?(:active_storage_blobs, :service_name)
+ add_column :active_storage_blobs, :service_name, :string
+
+ if (configured_service = ActiveStorage::Blob.service.name)
+ ActiveStorage::Blob.unscoped.update_all(service_name: configured_service)
+ end
+
+ change_column :active_storage_blobs, :service_name, :string, null: false
+ end
+ end
+
+ def down
+ return unless table_exists?(:active_storage_blobs)
+
+ remove_column :active_storage_blobs, :service_name
+ end
+end
diff --git a/db/migrate/20260306071847_create_active_storage_variant_records.active_storage.rb b/db/migrate/20260306071847_create_active_storage_variant_records.active_storage.rb
new file mode 100644
index 0000000..95fd27f
--- /dev/null
+++ b/db/migrate/20260306071847_create_active_storage_variant_records.active_storage.rb
@@ -0,0 +1,28 @@
+# This migration comes from active_storage (originally 20191206030411)
+class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0]
+ def change
+ return unless table_exists?(:active_storage_blobs)
+
+ # Use Active Record's configured type for primary key
+ create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t|
+ t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type
+ t.string :variation_digest, null: false
+
+ t.index %i[blob_id variation_digest], name: "index_active_storage_variant_records_uniqueness", unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+ end
+
+ private
+
+ def primary_key_type
+ config = Rails.configuration.generators
+ config.options[config.orm][:primary_key_type] || :primary_key
+ end
+
+ def blobs_primary_key_type
+ pkey_name = connection.primary_key(:active_storage_blobs)
+ pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name }
+ pkey_column.bigint? ? :bigint : pkey_column.type
+ end
+end
diff --git a/db/migrate/20260306071848_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20260306071848_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb
new file mode 100644
index 0000000..93c8b85
--- /dev/null
+++ b/db/migrate/20260306071848_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb
@@ -0,0 +1,8 @@
+# This migration comes from active_storage (originally 20211119233751)
+class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0]
+ def change
+ return unless table_exists?(:active_storage_blobs)
+
+ change_column_null(:active_storage_blobs, :checksum, true)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9ddb6dd..e518962 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_03_01_010909) do
+ActiveRecord::Schema[7.2].define(version: 2026_03_06_071848) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..2be3af2
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,67 @@
+
+
+
+ The page you were looking for doesn't exist (404)
+
+
+
+
+
+
+
+
+
The page you were looking for doesn't exist.
+
You may have mistyped the address or the page may have moved.
+
+
If you are the application owner check the logs for more information.
+
+
+
diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html
new file mode 100644
index 0000000..7cf1e16
--- /dev/null
+++ b/public/406-unsupported-browser.html
@@ -0,0 +1,66 @@
+
+
+
+ Your browser is not supported (406)
+
+
+
+
+
+
+
+
+
Your browser is not supported.
+
Please upgrade your browser to continue.
+
+
+
+
diff --git a/public/500.html b/public/500.html
new file mode 100644
index 0000000..78a030a
--- /dev/null
+++ b/public/500.html
@@ -0,0 +1,66 @@
+
+
+
+ We're sorry, but something went wrong (500)
+
+
+
+
+
+
+
+
+
We're sorry, but something went wrong.
+
+
If you are the application owner check the logs for more information.
+
+
+
diff --git a/public/favicon.ico b/public/favicon.ico
index 6788a75..aaea393 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/icon-192.png b/public/icon-192.png
index 1aa9ddb..1d661c1 100644
Binary files a/public/icon-192.png and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
index d6534d0..77357e1 100644
Binary files a/public/icon-512.png and b/public/icon-512.png differ
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..77357e1
Binary files /dev/null and b/public/icon.png differ
diff --git a/public/icon.svg b/public/icon.svg
index 78aec7a..708757d 100644
--- a/public/icon.svg
+++ b/public/icon.svg
@@ -1,3 +1,16 @@
-
\ No newline at end of file
+
+
+
diff --git a/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb b/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb
new file mode 100644
index 0000000..8a209ee
--- /dev/null
+++ b/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb
@@ -0,0 +1,192 @@
+require "test_helper"
+
+class Api::V1::Shopkeeper::Accounts::AccountsInvitationsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ @invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "invited@example.com",
+ invited_by: @shopkeeper,
+ junior_member: true
+ )
+ end
+
+ test "index returns all invitations for account" do
+ AccountsInvitation.create!(
+ account: @account,
+ name: "Another User",
+ email: "another@example.com",
+ junior_member: true
+ )
+
+ get api_v1_shopkeeper_account_accounts_invitations_url(@account),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal 2, response.parsed_body["data"].length
+ end
+
+ test "show returns invitation details" do
+ get api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal @invitation.name, response.parsed_body["data"]["attributes"]["name"]
+ end
+
+ test "create creates and sends invitation" do
+ assert_difference "AccountsInvitation.count", 1 do
+ post api_v1_shopkeeper_account_accounts_invitations_url(@account),
+ params: {
+ accounts_invitation: {
+ name: "New User",
+ email: "newuser@example.com",
+ junior_member: true
+ }
+ },
+ headers: @shopkeeper.create_new_auth_token
+ end
+
+ assert_response :created
+ assert_enqueued_emails 1
+ end
+
+ test "create returns error for invalid data" do
+ assert_no_difference "AccountsInvitation.count" do
+ post api_v1_shopkeeper_account_accounts_invitations_url(@account),
+ params: {
+ accounts_invitation: {
+ name: "",
+ email: "invalid@example.com",
+ junior_member: true
+ }
+ },
+ headers: @shopkeeper.create_new_auth_token
+ end
+
+ assert_response :unprocessable_entity
+ end
+
+ test "create requires admin role" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ post api_v1_shopkeeper_account_accounts_invitations_url(@account),
+ params: {
+ accounts_invitation: {
+ name: "New User",
+ email: "newuser@example.com",
+ junior_member: true
+ }
+ },
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "update updates invitation" do
+ patch api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ params: {
+ accounts_invitation: {
+ name: "Updated Name",
+ senior_member: true,
+ junior_member: false
+ }
+ },
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal "Updated Name", @invitation.reload.name
+ assert @invitation.senior_member?
+ end
+
+ test "update returns error for invalid data" do
+ patch api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ params: {
+ accounts_invitation: {
+ name: ""
+ }
+ },
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :unprocessable_entity
+ end
+
+ test "update requires admin role" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ patch api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ params: {accounts_invitation: {name: "Updated"}},
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "destroy deletes invitation" do
+ assert_difference "AccountsInvitation.count", -1 do
+ delete api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ headers: @shopkeeper.create_new_auth_token
+ end
+
+ assert_response :success
+ end
+
+ test "destroy requires admin role" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ delete api_v1_shopkeeper_account_accounts_invitation_url(@account, @invitation.token),
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "resend sends invitation email again" do
+ post resend_api_v1_shopkeeper_account_accounts_invitation_path(@account, @invitation.token),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ end
+
+ test "resend requires admin role" do
+ other_shopkeeper = Shopkeeper.create!(
+ name: "Other User",
+ email: "other123@example.com",
+ password: "password",
+ current_platform: "ios"
+ )
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ post resend_api_v1_shopkeeper_account_accounts_invitation_path(@account, @invitation.token),
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "requires authentication" do
+ get api_v1_shopkeeper_account_accounts_invitations_url(@account)
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb b/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb
new file mode 100644
index 0000000..89735ca
--- /dev/null
+++ b/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb
@@ -0,0 +1,77 @@
+require "test_helper"
+
+class Api::V1::Shopkeeper::AccountsInvitationsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "invited@example.com",
+ junior_member: true
+ )
+ end
+
+ test "show returns invitation details" do
+ get api_v1_shopkeeper_accounts_invitation_url(@invitation.token),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+
+ json = response.parsed_body
+ assert_equal @invitation.name, json["data"]["attributes"]["name"]
+ assert_equal @invitation.email, json["data"]["attributes"]["email"]
+ end
+
+ test "show returns 404 for invalid token" do
+ get api_v1_shopkeeper_accounts_invitation_url("invalid"),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :not_found
+ end
+
+ test "update accepts invitation" do
+ other_shopkeeper = shopkeepers(:two)
+ # Note: other_shopkeeper.create_default_account creates 1 AccountsShopkeeper
+ # and accepting invitation creates another, so count increases by 2
+ assert_difference "AccountsShopkeeper.count", 2 do
+ assert_difference "AccountsInvitation.count", -1 do
+ patch api_v1_shopkeeper_accounts_invitation_url(@invitation.token),
+ headers: other_shopkeeper.create_new_auth_token
+ end
+ end
+
+ assert_response :success
+ end
+
+ test "update returns error when invitation cannot be accepted" do
+ # Shopkeeper already in account
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: shopkeepers(:two),
+ junior_member: true
+ )
+
+ patch api_v1_shopkeeper_accounts_invitation_url(@invitation.token),
+ headers: shopkeepers(:two).create_new_auth_token
+
+ assert_response :unprocessable_entity
+ assert response.parsed_body["error_message"].present?
+ end
+
+ test "destroy rejects invitation" do
+ assert_difference "AccountsInvitation.count", -1 do
+ delete api_v1_shopkeeper_accounts_invitation_url(@invitation.token),
+ headers: @shopkeeper.create_new_auth_token
+ end
+
+ assert_response :success
+ end
+
+ test "requires authentication" do
+ get api_v1_shopkeeper_accounts_invitation_url(@invitation.token)
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/api/v1/shopkeeper/accounts_shopkeepers_controller_test.rb b/test/controllers/api/v1/shopkeeper/accounts_shopkeepers_controller_test.rb
new file mode 100644
index 0000000..68f05af
--- /dev/null
+++ b/test/controllers/api/v1/shopkeeper/accounts_shopkeepers_controller_test.rb
@@ -0,0 +1,151 @@
+require "test_helper"
+
+class Api::V1::Shopkeeper::AccountsShopkeepersControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ # Create a team account for testing
+ @team_account = Account.create!(name: "Team Account", owner: @shopkeeper, personal: false)
+ @team_accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: @shopkeeper,
+ admin: true
+ )
+ end
+
+ test "index returns empty array for personal account" do
+ get api_v1_shopkeeper_account_accounts_shopkeepers_url(@account),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal [], response.parsed_body["data"]
+ end
+
+ test "index returns accounts_shopkeepers for team account" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ get api_v1_shopkeeper_account_accounts_shopkeepers_url(@team_account),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal 2, response.parsed_body["data"].length
+ end
+
+ test "show returns accounts_shopkeeper details" do
+ get api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, @team_accounts_shopkeeper),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal @team_accounts_shopkeeper.id.to_s, response.parsed_body["data"]["id"]
+ end
+
+ test "show returns error for personal account" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+
+ get api_v1_shopkeeper_account_accounts_shopkeeper_url(@account, accounts_shopkeeper),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :unprocessable_entity
+ end
+
+ test "update updates accounts_shopkeeper roles" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ patch api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, accounts_shopkeeper),
+ params: {accounts_shopkeeper: {senior_member: true, junior_member: false}},
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert accounts_shopkeeper.reload.senior_member?
+ assert_not accounts_shopkeeper.junior_member?
+ end
+
+ test "update returns error for personal account" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+
+ patch api_v1_shopkeeper_account_accounts_shopkeeper_url(@account, accounts_shopkeeper),
+ params: {accounts_shopkeeper: {admin: false, junior_member: true}},
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :unprocessable_entity
+ end
+
+ test "update requires admin role" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ patch api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, @team_accounts_shopkeeper),
+ params: {accounts_shopkeeper: {senior_member: true}},
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "destroy removes accounts_shopkeeper" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ assert_difference "AccountsShopkeeper.count", -1 do
+ delete api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, accounts_shopkeeper),
+ headers: @shopkeeper.create_new_auth_token
+ end
+
+ assert_response :success
+ end
+
+ test "destroy prevents account owner deletion" do
+ delete api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, @team_accounts_shopkeeper),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "destroy returns error for personal account" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+
+ delete api_v1_shopkeeper_account_accounts_shopkeeper_url(@account, accounts_shopkeeper),
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :unprocessable_entity
+ end
+
+ test "destroy requires admin role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @team_account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ delete api_v1_shopkeeper_account_accounts_shopkeeper_url(@team_account, accounts_shopkeeper),
+ headers: other_shopkeeper.create_new_auth_token
+
+ assert_response :unauthorized
+ end
+
+ test "requires authentication" do
+ get api_v1_shopkeeper_account_accounts_shopkeepers_url(@team_account)
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/api/v1/shopkeeper/me_controller_test.rb b/test/controllers/api/v1/shopkeeper/me_controller_test.rb
new file mode 100644
index 0000000..fe0b065
--- /dev/null
+++ b/test/controllers/api/v1/shopkeeper/me_controller_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class Api::V1::Shopkeeper::MeControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ end
+
+ test "update_confirmed_privacy_version updates shopkeeper privacy version" do
+ current_version = PrivacyVersion.current_version
+ @shopkeeper.update!(confirmed_privacy_version: "0.0.0")
+
+ patch update_confirmed_privacy_version_api_v1_shopkeeper_me_path,
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal current_version, @shopkeeper.reload.confirmed_privacy_version
+ end
+
+ test "update_confirmed_terms_version updates shopkeeper terms version" do
+ current_version = TermsVersion.current_version
+ @shopkeeper.update!(confirmed_terms_version: "0.0.0")
+
+ patch update_confirmed_terms_version_api_v1_shopkeeper_me_path,
+ headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal current_version, @shopkeeper.reload.confirmed_terms_version
+ end
+
+ test "update_confirmed_privacy_version requires authentication" do
+ patch update_confirmed_privacy_version_api_v1_shopkeeper_me_path
+
+ assert_response :unauthorized
+ end
+
+ test "update_confirmed_terms_version requires authentication" do
+ patch update_confirmed_terms_version_api_v1_shopkeeper_me_path
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/api/v1/shopkeeper/permissions_controller_test.rb b/test/controllers/api/v1/shopkeeper/permissions_controller_test.rb
new file mode 100644
index 0000000..59c0d24
--- /dev/null
+++ b/test/controllers/api/v1/shopkeeper/permissions_controller_test.rb
@@ -0,0 +1,68 @@
+require "test_helper"
+
+class Api::V1::Shopkeeper::PermissionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ end
+
+ test "index returns permissions and metadata" do
+ get api_v1_shopkeeper_permissions_url, headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+
+ json = response.parsed_body
+ assert json["data"].present?
+ assert json["meta"].present?
+ assert json["meta"]["ios_app_version"].present?
+ assert json["meta"]["android_app_version"].present?
+ assert_not_nil json["meta"]["should_update_privacy"]
+ assert_not_nil json["meta"]["should_update_terms"]
+ assert json["meta"]["maximum_queue_number_length"].present?
+ assert json["meta"]["shop_limit_count"].present?
+ assert json["meta"]["account_limit_count"].present?
+ assert json["meta"]["accounts_shopkeeper_limit_count"].present?
+ end
+
+ test "index sets should_update_privacy to true when version is outdated" do
+ @shopkeeper.update!(confirmed_privacy_version: "0.0.0")
+
+ get api_v1_shopkeeper_permissions_url, headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal true, response.parsed_body["meta"]["should_update_privacy"]
+ end
+
+ test "index sets should_update_privacy to false when version is current" do
+ @shopkeeper.update!(confirmed_privacy_version: PrivacyVersion.current_version)
+
+ get api_v1_shopkeeper_permissions_url, headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal false, response.parsed_body["meta"]["should_update_privacy"]
+ end
+
+ test "index sets should_update_terms to true when version is outdated" do
+ @shopkeeper.update!(confirmed_terms_version: "0.0.0")
+
+ get api_v1_shopkeeper_permissions_url, headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal true, response.parsed_body["meta"]["should_update_terms"]
+ end
+
+ test "index sets should_update_terms to false when version is current" do
+ @shopkeeper.update!(confirmed_terms_version: TermsVersion.current_version)
+
+ get api_v1_shopkeeper_permissions_url, headers: @shopkeeper.create_new_auth_token
+
+ assert_response :success
+ assert_equal false, response.parsed_body["meta"]["should_update_terms"]
+ end
+
+ test "index requires authentication" do
+ get api_v1_shopkeeper_permissions_url
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/display/item_tags_controller_test.rb b/test/controllers/display/item_tags_controller_test.rb
new file mode 100644
index 0000000..29d3b75
--- /dev/null
+++ b/test/controllers/display/item_tags_controller_test.rb
@@ -0,0 +1,60 @@
+require "test_helper"
+
+class Display::ItemTagsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ ActsAsTenant.with_tenant(@account) do
+ @shop = @account.shops.first
+
+ # Create some completed item tags for testing
+ @item_tag1 = @shop.item_tags.first
+ @item_tag1.complete_tag!(@shopkeeper)
+
+ @item_tag2 = @shop.item_tags.create!(
+ queue_number: "B001",
+ created_by: @shopkeeper
+ )
+ @item_tag2.complete_tag!(@shopkeeper)
+ end
+ end
+
+ test "should get completings" do
+ get completings_display_shop_item_tags_url(@shop)
+ assert_response :success
+ end
+
+ test "should get completings with type param" do
+ get completings_display_shop_item_tags_url(@shop), params: {type: "customer"}
+ assert_response :success
+ end
+
+ test "should get completings with item_tag_id param" do
+ get completings_display_shop_item_tags_url(@shop), params: {type: "customer", item_tag_id: @item_tag1.id}
+ assert_response :success
+ end
+
+ test "should paginate completed item tags" do
+ get completings_display_shop_item_tags_url(@shop)
+ assert_response :success
+ end
+
+ test "should only show completed item tags" do
+ ActsAsTenant.with_tenant(@account) do
+ @shop.item_tags.create!(
+ queue_number: "C001",
+ created_by: @shopkeeper
+ )
+
+ get completings_display_shop_item_tags_url(@shop)
+ assert_response :success
+ end
+ end
+
+ test "should return not found for invalid shop" do
+ get completings_display_shop_item_tags_url(shop_id: "invalid-id")
+ assert_response :not_found
+ end
+end
diff --git a/test/controllers/display/shops_controller_test.rb b/test/controllers/display/shops_controller_test.rb
new file mode 100644
index 0000000..5b70b67
--- /dev/null
+++ b/test/controllers/display/shops_controller_test.rb
@@ -0,0 +1,36 @@
+require "test_helper"
+
+class Display::ShopsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ ActsAsTenant.with_tenant(@account) do
+ @shop = @account.shops.first
+ end
+ end
+
+ test "should show shop" do
+ get display_shop_url(@shop)
+ assert_response :success
+ end
+
+ test "should show shop with type param" do
+ get display_shop_url(@shop), params: {type: "customer"}
+ assert_response :success
+ end
+
+ test "should show shop with item_tag_id param" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ get display_shop_url(@shop), params: {type: "customer", item_tag_id: item_tag.id}
+ assert_response :success
+ end
+ end
+
+ test "should return not found for invalid shop" do
+ get display_shop_url(id: "invalid-id")
+ assert_response :not_found
+ end
+end
diff --git a/test/controllers/shopkeeper_auth/confirmations_controller_test.rb b/test/controllers/shopkeeper_auth/confirmations_controller_test.rb
new file mode 100644
index 0000000..4f532cb
--- /dev/null
+++ b/test/controllers/shopkeeper_auth/confirmations_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class ShopkeeperAuth::ConfirmationsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @email = @shopkeeper.email
+ end
+
+ test "should send confirmation instructions" do
+ post shopkeeper_confirmation_url,
+ params: {
+ email: @email,
+ redirect_url: "http://localhost:3000/confirm"
+ },
+ as: :json
+
+ assert_response :success
+ end
+
+ test "should return error when email is missing" do
+ post shopkeeper_confirmation_url,
+ params: {redirect_url: "http://localhost:3000/confirm"},
+ as: :json
+
+ assert_response :unauthorized
+ assert_equal 401, JSON.parse(response.body)["code"]
+ end
+
+ test "should return not found for non-existent email" do
+ post shopkeeper_confirmation_url,
+ params: {
+ email: "nonexistent@example.com",
+ redirect_url: "http://localhost:3000/confirm"
+ },
+ as: :json
+
+ assert_response :not_found
+ assert_equal 404, JSON.parse(response.body)["code"]
+ end
+
+ test "should use default redirect_url when not provided" do
+ post shopkeeper_confirmation_url,
+ params: {email: @email},
+ as: :json
+
+ assert_response :success
+ end
+end
diff --git a/test/controllers/shopkeeper_auth/passwords_controller_test.rb b/test/controllers/shopkeeper_auth/passwords_controller_test.rb
new file mode 100644
index 0000000..339763a
--- /dev/null
+++ b/test/controllers/shopkeeper_auth/passwords_controller_test.rb
@@ -0,0 +1,49 @@
+require "test_helper"
+
+class ShopkeeperAuth::PasswordsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @email = @shopkeeper.email
+ end
+
+ test "should send reset password instructions" do
+ post shopkeeper_password_url,
+ params: {
+ email: @email,
+ redirect_url: "http://localhost:3000/reset"
+ },
+ as: :json
+
+ assert_response :success
+ end
+
+ test "should return error when email is missing" do
+ post shopkeeper_password_url,
+ params: {redirect_url: "http://localhost:3000/reset"},
+ as: :json
+
+ assert_response :unauthorized
+ assert_equal 401, JSON.parse(response.body)["code"]
+ end
+
+ test "should return error when redirect_url is missing" do
+ post shopkeeper_password_url,
+ params: {email: @email},
+ as: :json
+
+ assert_response :unauthorized
+ assert_equal 401, JSON.parse(response.body)["code"]
+ end
+
+ test "should return not found for non-existent email" do
+ post shopkeeper_password_url,
+ params: {
+ email: "nonexistent@example.com",
+ redirect_url: "http://localhost:3000/reset"
+ },
+ as: :json
+
+ assert_response :not_found
+ assert_equal 404, JSON.parse(response.body)["code"]
+ end
+end
diff --git a/test/controllers/static_controller_test.rb b/test/controllers/static_controller_test.rb
new file mode 100644
index 0000000..c2d2c53
--- /dev/null
+++ b/test/controllers/static_controller_test.rb
@@ -0,0 +1,35 @@
+require "test_helper"
+
+class StaticControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ ActsAsTenant.with_tenant(@account) do
+ @shop = @account.shops.first
+ @item_tag = @shop.item_tags.first
+ end
+ end
+
+ test "should get index" do
+ get root_url
+ assert_response :success
+ end
+
+ test "scan should redirect when type is server" do
+ get scan_url, params: {type: "server", item_tag_id: @item_tag.id}
+ assert_redirected_to ConfigSettings.site.url
+ end
+
+ test "scan_customer should scan tag and redirect when type is customer" do
+ ActsAsTenant.with_tenant(@account) do
+ assert_equal "unscanned", @item_tag.scan_state
+
+ get scan_customer_url, params: {type: "customer", item_tag_id: @item_tag.id}
+
+ assert_redirected_to display_shop_path(@shop, params: {type: "customer", item_tag_id: @item_tag.id})
+ assert_equal "scanned", @item_tag.reload.scan_state
+ end
+ end
+end
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
new file mode 100644
index 0000000..c223899
--- /dev/null
+++ b/test/models/account_test.rb
@@ -0,0 +1,143 @@
+require "test_helper"
+
+class AccountTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ end
+
+ test "should be valid with valid attributes" do
+ account = Account.new(name: "Test Account", owner: @shopkeeper)
+ assert account.valid?
+ end
+
+ test "should require name" do
+ account = Account.new(owner: @shopkeeper)
+ assert_not account.valid?
+ assert_includes account.errors[:name], "can't be blank"
+ end
+
+ test "should belong to owner" do
+ account = Account.new(name: "Test Account", owner: @shopkeeper)
+ assert_equal @shopkeeper, account.owner
+ end
+
+ test "should create default shop after creation" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ assert_equal 1, account.shops.count
+ assert_equal ConfigSettings.shop.default_name, account.shops.first.name
+ end
+
+ test "personal? scope returns personal accounts" do
+ personal_account = Account.create!(name: "Personal", owner: @shopkeeper, personal: true)
+ team_account = Account.create!(name: "Team", owner: @shopkeeper, personal: false)
+
+ assert_includes Account.personal, personal_account
+ assert_not_includes Account.personal, team_account
+ end
+
+ test "team scope returns team accounts" do
+ personal_account = Account.create!(name: "Personal", owner: @shopkeeper, personal: true)
+ team_account = Account.create!(name: "Team", owner: @shopkeeper, personal: false)
+
+ assert_includes Account.team, team_account
+ assert_not_includes Account.team, personal_account
+ end
+
+ test "personal_account_for? returns true for owner's personal account" do
+ account = Account.create!(name: "Personal", owner: @shopkeeper, personal: true)
+ assert account.personal_account_for?(@shopkeeper)
+ end
+
+ test "personal_account_for? returns false for team account" do
+ account = Account.create!(name: "Team", owner: @shopkeeper, personal: false)
+ assert_not account.personal_account_for?(@shopkeeper)
+ end
+
+ test "owner? returns true for account owner" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ assert account.owner?(@shopkeeper)
+ end
+
+ test "owner? returns false for non-owner" do
+ other_shopkeeper = shopkeepers(:two)
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ assert_not account.owner?(other_shopkeeper)
+ end
+
+ test "admin? returns true when shopkeeper is admin" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ AccountsShopkeeper.create!(
+ account: account,
+ shopkeeper: @shopkeeper,
+ admin: true
+ )
+
+ assert account.admin?(@shopkeeper)
+ end
+
+ test "admin? returns false when shopkeeper is not admin" do
+ other_shopkeeper = shopkeepers(:two)
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ AccountsShopkeeper.create!(
+ account: account,
+ shopkeeper: other_shopkeeper,
+ admin: false,
+ junior_member: true
+ )
+
+ assert_not account.admin?(other_shopkeeper)
+ end
+
+ test "admin? returns false when shopkeeper is not a member" do
+ other_shopkeeper = shopkeepers(:two)
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+
+ assert_not account.admin?(other_shopkeeper)
+ end
+
+ test "should destroy associated accounts_invitations" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ AccountsInvitation.create!(
+ account: account,
+ name: "Test User",
+ email: "test@example.com",
+ junior_member: true
+ )
+
+ assert_difference "AccountsInvitation.count", -1 do
+ account.destroy
+ end
+ end
+
+ test "should destroy associated accounts_shopkeepers" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+ AccountsShopkeeper.create!(
+ account: account,
+ shopkeeper: @shopkeeper,
+ admin: true
+ )
+
+ assert_difference "AccountsShopkeeper.count", -1 do
+ account.destroy
+ end
+ end
+
+ test "should destroy associated shops" do
+ account = Account.create!(name: "Test Account", owner: @shopkeeper)
+
+ assert_difference "Shop.count", -1 do
+ account.destroy
+ end
+ end
+
+ test "sorted scope orders personal accounts first, then by name" do
+ account_b_team = Account.create!(name: "B Team", owner: @shopkeeper, personal: false)
+ account_a_personal = Account.create!(name: "A Personal", owner: @shopkeeper, personal: true)
+ account_c_team = Account.create!(name: "C Team", owner: @shopkeeper, personal: false)
+
+ sorted_accounts = Account.sorted.where(id: [account_b_team.id, account_a_personal.id, account_c_team.id])
+
+ assert_equal account_a_personal.id, sorted_accounts.first.id
+ assert_equal [account_b_team.id, account_c_team.id], sorted_accounts.last(2).map(&:id)
+ end
+end
diff --git a/test/models/accounts_invitation_test.rb b/test/models/accounts_invitation_test.rb
new file mode 100644
index 0000000..7d67486
--- /dev/null
+++ b/test/models/accounts_invitation_test.rb
@@ -0,0 +1,277 @@
+require "test_helper"
+
+class AccountsInvitationTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ end
+
+ test "should be valid with valid attributes" do
+ invitation = AccountsInvitation.new(
+ account: @account,
+ name: "Invited User",
+ email: "invited@example.com",
+ invited_by: @shopkeeper,
+ junior_member: true
+ )
+ assert invitation.valid?
+ end
+
+ test "should require name" do
+ invitation = AccountsInvitation.new(
+ account: @account,
+ email: "invited@example.com",
+ junior_member: true
+ )
+ assert_not invitation.valid?
+ assert_includes invitation.errors[:name], "can't be blank"
+ end
+
+ test "should require email" do
+ invitation = AccountsInvitation.new(
+ account: @account,
+ name: "Invited User",
+ junior_member: true
+ )
+ assert_not invitation.valid?
+ assert_includes invitation.errors[:email], "can't be blank"
+ end
+
+ test "should validate uniqueness of email within account" do
+ AccountsInvitation.create!(
+ account: @account,
+ name: "User 1",
+ email: "same@example.com",
+ junior_member: true
+ )
+
+ duplicate = AccountsInvitation.new(
+ account: @account,
+ name: "User 2",
+ email: "same@example.com",
+ junior_member: true
+ )
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:email], I18n.t("activerecord.errors.models.accounts_invitation.attributes.email.invited")
+ end
+
+ test "should allow same email in different accounts" do
+ other_shopkeeper = shopkeepers(:two)
+ account2 = Account.create!(name: "Account 2", owner: other_shopkeeper)
+
+ AccountsInvitation.create!(
+ account: @account,
+ name: "User 1",
+ email: "same@example.com",
+ junior_member: true
+ )
+
+ invitation2 = AccountsInvitation.new(
+ account: account2,
+ name: "User 2",
+ email: "same@example.com",
+ junior_member: true
+ )
+
+ assert invitation2.valid?
+ end
+
+ test "should generate token before creation" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "token@example.com",
+ junior_member: true
+ )
+
+ assert_not_nil invitation.token
+ assert_equal 6, invitation.token.length
+ assert invitation.token.match?(/\A\d{6}\z/)
+ end
+
+ test "should generate unique tokens" do
+ invitation1 = AccountsInvitation.create!(
+ account: @account,
+ name: "User 1",
+ email: "user1@example.com",
+ junior_member: true
+ )
+
+ invitation2 = AccountsInvitation.create!(
+ account: @account,
+ name: "User 2",
+ email: "user2@example.com",
+ junior_member: true
+ )
+
+ assert_not_equal invitation1.token, invitation2.token
+ end
+
+ test "to_param returns token" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "param@example.com",
+ junior_member: true
+ )
+
+ assert_equal invitation.token, invitation.to_param
+ end
+
+ test "accept! creates accounts_shopkeeper with correct roles" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "accept@example.com",
+ senior_manager: true
+ )
+
+ other_shopkeeper = shopkeepers(:two)
+
+ assert_difference "AccountsShopkeeper.count", 1 do
+ result = invitation.accept!(other_shopkeeper)
+ assert_not_nil result
+ assert result.is_a?(AccountsShopkeeper)
+ end
+
+ accounts_shopkeeper = AccountsShopkeeper.find_by(
+ account: @account,
+ shopkeeper: other_shopkeeper
+ )
+
+ assert accounts_shopkeeper.senior_manager?
+ end
+
+ test "accept! destroys invitation after creating accounts_shopkeeper" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "destroy@example.com",
+ junior_member: true
+ )
+
+ other_shopkeeper = shopkeepers(:two)
+
+ assert_difference "AccountsInvitation.count", -1 do
+ invitation.accept!(other_shopkeeper)
+ end
+ end
+
+ test "accept! returns nil and adds error if accounts_shopkeeper is invalid" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "invalid@example.com",
+ junior_member: true
+ )
+
+ # Create a shopkeeper that's already a member
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ result = invitation.accept!(other_shopkeeper)
+
+ assert_nil result
+ assert invitation.errors[:base].present?
+ end
+
+ test "accept! is atomic - rolls back if accounts_shopkeeper fails" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "atomic@example.com",
+ junior_member: true
+ )
+
+ other_shopkeeper = shopkeepers(:two)
+ # Create existing membership
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ initial_invitation_count = AccountsInvitation.count
+ initial_as_count = AccountsShopkeeper.count
+
+ invitation.accept!(other_shopkeeper)
+
+ # Invitation should not be destroyed if accounts_shopkeeper creation fails
+ assert_equal initial_invitation_count, AccountsInvitation.count
+ assert_equal initial_as_count, AccountsShopkeeper.count
+ end
+
+ test "reject! destroys the invitation" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "reject@example.com",
+ junior_member: true
+ )
+
+ assert_difference "AccountsInvitation.count", -1 do
+ invitation.reject!
+ end
+ end
+
+ test "role helper methods work correctly" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "roles@example.com",
+ admin: true
+ )
+
+ assert invitation.admin?
+ assert_not invitation.senior_manager?
+ end
+
+ test "active_roles returns array of assigned roles" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "active@example.com",
+ admin: true,
+ senior_member: true
+ )
+
+ active_roles = invitation.active_roles
+ assert_includes active_roles, :admin
+ assert_includes active_roles, :senior_member
+ assert_equal 2, active_roles.length
+ end
+
+ test "save_and_send_invite saves and sends invitation email" do
+ invitation = AccountsInvitation.new(
+ account: @account,
+ name: "Invited User",
+ email: "send@example.com",
+ junior_member: true
+ )
+
+ assert_difference "AccountsInvitation.count", 1 do
+ result = invitation.save_and_send_invite
+ assert result
+ end
+ end
+
+ test "send_invite sends invitation email" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "send2@example.com",
+ junior_member: true
+ )
+
+ # Email delivery is tested by the fact that the method doesn't raise an error
+ assert_nothing_raised do
+ invitation.send_invite
+ end
+ end
+end
diff --git a/test/models/accounts_shopkeeper_test.rb b/test/models/accounts_shopkeeper_test.rb
new file mode 100644
index 0000000..2c1f330
--- /dev/null
+++ b/test/models/accounts_shopkeeper_test.rb
@@ -0,0 +1,240 @@
+require "test_helper"
+
+class AccountsShopkeeperTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ end
+
+ test "should be valid with valid attributes" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.new(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+ assert accounts_shopkeeper.valid?
+ end
+
+ test "should require shopkeeper" do
+ accounts_shopkeeper = AccountsShopkeeper.new(account: @account, junior_member: true)
+ assert_not accounts_shopkeeper.valid?
+ assert_includes accounts_shopkeeper.errors[:shopkeeper], "must exist"
+ end
+
+ test "should validate uniqueness of shopkeeper within account" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ duplicate = AccountsShopkeeper.new(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true
+ )
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:shopkeeper_id], "has already been taken"
+ end
+
+ test "should allow same shopkeeper in different accounts" do
+ other_shopkeeper = shopkeepers(:two)
+ account2 = Account.create!(name: "Account 2", owner: other_shopkeeper)
+
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ accounts_shopkeeper2 = AccountsShopkeeper.new(
+ account: account2,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ assert accounts_shopkeeper2.valid?
+ end
+
+ test "should touch account when updated" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ old_updated_at = @account.updated_at
+
+ sleep 0.01
+ accounts_shopkeeper.update!(senior_member: true)
+
+ assert @account.reload.updated_at > old_updated_at
+ end
+
+ test "account_owner? returns true when shopkeeper is account owner" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ assert accounts_shopkeeper.account_owner?
+ end
+
+ test "account_owner? returns false when shopkeeper is not account owner" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ assert_not accounts_shopkeeper.account_owner?
+ end
+
+ test "owner must be admin validation prevents removing admin from owner" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ assert accounts_shopkeeper.account_owner?
+ assert accounts_shopkeeper.admin?
+
+ accounts_shopkeeper.admin = false
+ accounts_shopkeeper.junior_member = true
+
+ assert_not accounts_shopkeeper.valid?
+ assert_includes accounts_shopkeeper.errors[:admin], I18n.t("activerecord.errors.models.accounts_shopkeeper.attributes.admin.cannot_be_removed")
+ end
+
+ test "owner must be admin validation allows admin changes for non-owners" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true
+ )
+
+ accounts_shopkeeper.admin = false
+ accounts_shopkeeper.junior_member = true
+
+ assert accounts_shopkeeper.valid?
+ end
+
+ test "permissions returns admin permissions for admin role" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ accounts_shopkeeper.update!(admin: true)
+
+ admin_role = Role.find_by(tag: "admin")
+ assert_equal admin_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "permissions returns senior_manager permissions for senior_manager role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_manager: true
+ )
+
+ senior_manager_role = Role.find_by(tag: "senior_manager")
+ assert_equal senior_manager_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "permissions returns junior_manager permissions for junior_manager role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_manager: true
+ )
+
+ junior_manager_role = Role.find_by(tag: "junior_manager")
+ assert_equal junior_manager_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "permissions returns senior_member permissions for senior_member role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_member: true
+ )
+
+ senior_member_role = Role.find_by(tag: "senior_member")
+ assert_equal senior_member_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "permissions returns junior_member permissions for junior_member role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ junior_member_role = Role.find_by(tag: "junior_member")
+ assert_equal junior_member_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "permissions returns guest permissions for guest role" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ guest: true
+ )
+
+ guest_role = Role.find_by(tag: "guest")
+ assert_equal guest_role.permissions, accounts_shopkeeper.permissions
+ end
+
+ test "role helper methods work correctly" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true
+ )
+
+ assert accounts_shopkeeper.admin?
+ assert_not accounts_shopkeeper.senior_manager?
+ assert_not accounts_shopkeeper.junior_manager?
+ assert_not accounts_shopkeeper.senior_member?
+ assert_not accounts_shopkeeper.junior_member?
+ assert_not accounts_shopkeeper.guest?
+ end
+
+ test "active_roles returns array of active roles" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true,
+ senior_member: true
+ )
+
+ active_roles = accounts_shopkeeper.active_roles
+ assert_includes active_roles, :admin
+ assert_includes active_roles, :senior_member
+ assert_equal 2, active_roles.length
+ end
+
+ test "role scopes filter correctly" do
+ other_shopkeeper = shopkeepers(:two)
+ admin_as = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true
+ )
+
+ shopkeeper3 = Shopkeeper.create!(
+ name: "Shopkeeper Three",
+ email: "three@example.com",
+ password: "password",
+ current_platform: "ios"
+ )
+ member_as = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: shopkeeper3,
+ junior_member: true
+ )
+
+ assert_includes AccountsShopkeeper.admin, admin_as
+ assert_not_includes AccountsShopkeeper.admin, member_as
+
+ assert_includes AccountsShopkeeper.junior_member, member_as
+ assert_not_includes AccountsShopkeeper.junior_member, admin_as
+ end
+end
diff --git a/test/models/app_version_test.rb b/test/models/app_version_test.rb
new file mode 100644
index 0000000..bcc9427
--- /dev/null
+++ b/test/models/app_version_test.rb
@@ -0,0 +1,111 @@
+require "test_helper"
+
+class AppVersionTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ app_version = AppVersion.new(
+ platform: "ios",
+ version: 2,
+ current_type: :current,
+ forced_update_type: :unforced_update,
+ title: "Test Version",
+ description: "Test Description"
+ )
+ assert app_version.valid?
+ end
+
+ test "should have current_type enum" do
+ app_version = AppVersion.new(
+ platform: "ios",
+ version: 2,
+ current_type: :current,
+ forced_update_type: :unforced_update
+ )
+
+ assert app_version.current?
+ assert_not app_version.uncurrent?
+
+ app_version.current_type = :uncurrent
+ assert app_version.uncurrent?
+ assert_not app_version.current?
+ end
+
+ test "should have forced_update_type enum" do
+ app_version = AppVersion.new(
+ platform: "ios",
+ version: 2,
+ current_type: :current,
+ forced_update_type: :forced_update
+ )
+
+ assert app_version.forced_update?
+ assert_not app_version.unforced_update?
+
+ app_version.forced_update_type = :unforced_update
+ assert app_version.unforced_update?
+ assert_not app_version.forced_update?
+ end
+
+ test "current_version should return latest current version for platform" do
+ ios_version = AppVersion.current_version(platform: "ios")
+ assert_not_nil ios_version
+ assert_kind_of Integer, ios_version
+
+ android_version = AppVersion.current_version(platform: "android")
+ assert_not_nil android_version
+ assert_kind_of Integer, android_version
+ end
+
+ test "current_version should return highest version for platform" do
+ AppVersion.create!(
+ platform: "ios",
+ version: 10,
+ current_type: :current,
+ forced_update_type: :unforced_update,
+ title: "Version 10",
+ description: "Test"
+ )
+
+ AppVersion.create!(
+ platform: "ios",
+ version: 15,
+ current_type: :current,
+ forced_update_type: :unforced_update,
+ title: "Version 15",
+ description: "Test"
+ )
+
+ assert_equal 15, AppVersion.current_version(platform: "ios")
+ end
+
+ test "current_version should only return current versions" do
+ AppVersion.create!(
+ platform: "android",
+ version: 20,
+ current_type: :uncurrent,
+ forced_update_type: :unforced_update,
+ title: "Uncurrent",
+ description: "Test"
+ )
+
+ AppVersion.create!(
+ platform: "android",
+ version: 5,
+ current_type: :current,
+ forced_update_type: :unforced_update,
+ title: "Current",
+ description: "Test"
+ )
+
+ assert_equal 5, AppVersion.current_version(platform: "android")
+ end
+
+ test "should load from fixtures" do
+ assert AppVersion.count > 0
+
+ ios_version = AppVersion.find_by(platform: "ios")
+ assert_not_nil ios_version
+
+ android_version = AppVersion.find_by(platform: "android")
+ assert_not_nil android_version
+ end
+end
diff --git a/test/models/item_tag_test.rb b/test/models/item_tag_test.rb
new file mode 100644
index 0000000..da6bbbd
--- /dev/null
+++ b/test/models/item_tag_test.rb
@@ -0,0 +1,253 @@
+require "test_helper"
+
+class ItemTagTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @shop = @account.shops.first
+ end
+
+ test "should be valid with valid attributes" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.new(queue_number: "B01", account: @account)
+ assert item_tag.valid?
+ end
+ end
+
+ test "should require queue_number" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.new(account: @account)
+ assert_not item_tag.valid?
+ assert_includes item_tag.errors[:queue_number], "can't be blank"
+ end
+ end
+
+ test "should validate queue_number format" do
+ ActsAsTenant.with_tenant(@account) do
+ # Valid formats
+ assert @shop.item_tags.new(queue_number: "A1", account: @account).valid?
+ assert @shop.item_tags.new(queue_number: "AB", account: @account).valid?
+ assert @shop.item_tags.new(queue_number: "A01", account: @account).valid?
+ assert @shop.item_tags.new(queue_number: "ABC12", account: @account).valid?
+
+ # Invalid formats
+ item_tag = @shop.item_tags.new(queue_number: "A", account: @account) # Too short
+ assert_not item_tag.valid?
+
+ item_tag = @shop.item_tags.new(queue_number: "ABCDEF", account: @account) # Too long
+ assert_not item_tag.valid?
+
+ item_tag = @shop.item_tags.new(queue_number: "A@1", account: @account) # Invalid character
+ assert_not item_tag.valid?
+ end
+ end
+
+ test "should validate uniqueness of queue_number within shop" do
+ ActsAsTenant.with_tenant(@account) do
+ @shop.item_tags.create!(queue_number: "B01", account: @account)
+ duplicate = @shop.item_tags.new(queue_number: "B01", account: @account)
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:queue_number], I18n.t("activerecord.errors.models.item_tag.attributes.queue_number.uniqueness_error")
+ end
+ end
+
+ test "should allow same queue_number in different shops" do
+ ActsAsTenant.with_tenant(@account) do
+ shop2 = @account.shops.create!(name: "Shop 2", created_by: @shopkeeper)
+
+ @shop.item_tags.create!(queue_number: "B01", account: @account)
+ item_tag2 = shop2.item_tags.create!(queue_number: "B01", account: @account)
+
+ assert item_tag2.valid?
+ end
+ end
+
+ test "should belong to shop" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert_equal @shop, item_tag.shop
+ end
+ end
+
+ test "should belong to account" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert_equal @account, item_tag.account
+ end
+ end
+
+ test "should have initial state of idled" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.create!(queue_number: "B01", account: @account)
+ assert item_tag.idled?
+ end
+ end
+
+ test "should have initial scan_state of unscanned" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.create!(queue_number: "B01", account: @account)
+ assert item_tag.unscanned?
+ end
+ end
+
+ test "complete! transitions from idled to completed" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert item_tag.idled?
+
+ item_tag.complete!
+
+ assert item_tag.completed?
+ end
+ end
+
+ test "idle! transitions from completed to idled" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.complete!
+
+ assert item_tag.completed?
+
+ item_tag.idle!
+
+ assert item_tag.idled?
+ end
+ end
+
+ test "scan! transitions from unscanned to scanned" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert item_tag.unscanned?
+
+ item_tag.scan!
+
+ assert item_tag.scanned?
+ end
+ end
+
+ test "unscan! transitions to unscanned" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.scan!
+
+ assert item_tag.scanned?
+
+ item_tag.unscan!
+
+ assert item_tag.unscanned?
+ end
+ end
+
+ test "scan_tag! sets customer_read_at and scans" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert_nil item_tag.customer_read_at
+
+ item_tag.scan_tag!
+
+ assert_not_nil item_tag.customer_read_at
+ assert item_tag.scanned?
+ end
+ end
+
+ test "scan_tag! does nothing if cannot scan" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.scan!
+
+ assert item_tag.scanned?
+
+ old_time = item_tag.customer_read_at
+ item_tag.scan_tag!
+
+ assert_equal old_time, item_tag.customer_read_at
+ end
+ end
+
+ test "complete_tag! sets completed_by and completed_at and completes" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ assert_nil item_tag.completed_by
+ assert_nil item_tag.completed_at
+
+ item_tag.complete_tag!(@shopkeeper)
+
+ assert_equal @shopkeeper, item_tag.completed_by
+ assert_not_nil item_tag.completed_at
+ assert item_tag.completed?
+ assert_equal false, item_tag.already_completed
+ end
+ end
+
+ test "complete_tag! does nothing if cannot complete" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.complete_tag!(@shopkeeper)
+
+ assert item_tag.completed?
+
+ item_tag.complete_tag!(shopkeepers(:two))
+
+ assert_equal @shopkeeper, item_tag.completed_by
+ end
+ end
+
+ test "reset! clears all completion data and resets states" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.scan_tag!
+ item_tag.complete_tag!(@shopkeeper)
+
+ assert item_tag.completed?
+ assert item_tag.scanned?
+ assert_not_nil item_tag.customer_read_at
+ assert_not_nil item_tag.completed_by
+ assert_not_nil item_tag.completed_at
+
+ item_tag.reset!
+
+ assert item_tag.idled?
+ assert item_tag.unscanned?
+ assert_nil item_tag.customer_read_at
+ assert_nil item_tag.completed_by_id
+ assert_nil item_tag.completed_at
+ assert_equal false, item_tag.already_completed
+ end
+ end
+
+ test "sorted scope orders by queue_number" do
+ ActsAsTenant.with_tenant(@account) do
+ # Clear existing tags
+ @shop.item_tags.destroy_all
+
+ item_tag_c = @shop.item_tags.create!(queue_number: "C01", account: @account)
+ item_tag_a = @shop.item_tags.create!(queue_number: "A01", account: @account)
+ item_tag_b = @shop.item_tags.create!(queue_number: "B01", account: @account)
+
+ sorted = @shop.item_tags.sorted
+
+ assert_equal [item_tag_a, item_tag_b, item_tag_c], sorted.to_a
+ end
+ end
+
+ test "sorted_recent_first_order scope orders by completed_at desc" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag1 = @shop.item_tags.first
+ item_tag2 = @shop.item_tags.second
+ item_tag3 = @shop.item_tags.third
+
+ item_tag1.complete_tag!(@shopkeeper)
+ sleep 0.01
+ item_tag2.complete_tag!(@shopkeeper)
+ sleep 0.01
+ item_tag3.complete_tag!(@shopkeeper)
+
+ sorted = @shop.item_tags.completed.sorted_recent_first_order
+
+ assert_equal item_tag3, sorted.first
+ assert_equal item_tag1, sorted.last
+ end
+ end
+end
diff --git a/test/models/permission_test.rb b/test/models/permission_test.rb
new file mode 100644
index 0000000..9f1be5b
--- /dev/null
+++ b/test/models/permission_test.rb
@@ -0,0 +1,52 @@
+require "test_helper"
+
+class PermissionTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ permission = Permission.new(
+ name: "Test Permission",
+ tag: "test_permission",
+ position: 100
+ )
+ assert permission.valid?
+ end
+
+ test "should have many roles_permissions" do
+ permission = Permission.first
+ assert_respond_to permission, :roles_permissions
+ end
+
+ test "should have many roles through roles_permissions" do
+ permission = Permission.first
+ assert_respond_to permission, :roles
+ end
+
+ test "should destroy associated roles_permissions when destroyed" do
+ permission = Permission.create!(
+ name: "Test Permission",
+ tag: "test_permission_destroy",
+ position: 999
+ )
+
+ role = Role.first
+ RolesPermission.create!(role: role, permission: permission)
+
+ assert_difference "RolesPermission.count", -1 do
+ permission.destroy
+ end
+ end
+
+ test "should be associated with roles through roles_permissions" do
+ permission = Permission.find_by(tag: "update_shops")
+ admin_role = Role.find_by(tag: "admin")
+
+ assert_includes permission.roles, admin_role
+ end
+
+ test "should load from fixtures" do
+ assert Permission.count > 0
+
+ permission = Permission.find_by(tag: "update_shops")
+ assert_not_nil permission
+ assert_equal "update shops", permission.name
+ end
+end
diff --git a/test/models/privacy_version_test.rb b/test/models/privacy_version_test.rb
new file mode 100644
index 0000000..140c26c
--- /dev/null
+++ b/test/models/privacy_version_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+
+class PrivacyVersionTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ privacy_version = PrivacyVersion.new(
+ version: 2,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Test Privacy Version",
+ description: "Test Description"
+ )
+ assert privacy_version.valid?
+ end
+
+ test "should have current_type enum" do
+ privacy_version = PrivacyVersion.new(
+ version: 2,
+ current_type: :current,
+ published_at: Date.today
+ )
+
+ assert privacy_version.current?
+ assert_not privacy_version.uncurrent?
+
+ privacy_version.current_type = :uncurrent
+ assert privacy_version.uncurrent?
+ assert_not privacy_version.current?
+ end
+
+ test "current_version should return latest current version" do
+ version = PrivacyVersion.current_version
+ assert_not_nil version
+ assert_kind_of Integer, version
+ end
+
+ test "current_version should return highest version number" do
+ PrivacyVersion.create!(
+ version: 10,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Version 10",
+ description: "Test"
+ )
+
+ PrivacyVersion.create!(
+ version: 15,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Version 15",
+ description: "Test"
+ )
+
+ assert_equal 15, PrivacyVersion.current_version
+ end
+
+ test "current_version should only return current versions" do
+ PrivacyVersion.create!(
+ version: 20,
+ current_type: :uncurrent,
+ published_at: Date.today,
+ title: "Uncurrent",
+ description: "Test"
+ )
+
+ PrivacyVersion.create!(
+ version: 5,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Current",
+ description: "Test"
+ )
+
+ assert_equal 5, PrivacyVersion.current_version
+ end
+
+ test "should load from fixtures" do
+ assert PrivacyVersion.count > 0
+
+ privacy_version = PrivacyVersion.first
+ assert_not_nil privacy_version
+ assert_not_nil privacy_version.version
+ assert_not_nil privacy_version.published_at
+ end
+end
diff --git a/test/models/role_test.rb b/test/models/role_test.rb
new file mode 100644
index 0000000..5d90e8e
--- /dev/null
+++ b/test/models/role_test.rb
@@ -0,0 +1,74 @@
+require "test_helper"
+
+class RoleTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ role = Role.new(
+ name: "Test Role",
+ tag: "test_role",
+ position: 100
+ )
+ assert role.valid?
+ end
+
+ test "should have many roles_permissions" do
+ role = Role.first
+ assert_respond_to role, :roles_permissions
+ end
+
+ test "should have many permissions through roles_permissions" do
+ role = Role.first
+ assert_respond_to role, :permissions
+ end
+
+ test "should destroy associated roles_permissions when destroyed" do
+ role = Role.create!(
+ name: "Test Role",
+ tag: "test_role_destroy",
+ position: 999
+ )
+
+ permission = Permission.first
+ RolesPermission.create!(role: role, permission: permission)
+
+ assert_difference "RolesPermission.count", -1 do
+ role.destroy
+ end
+ end
+
+ test "should be associated with permissions through roles_permissions" do
+ admin_role = Role.find_by(tag: "admin")
+ update_shops_permission = Permission.find_by(tag: "update_shops")
+
+ assert_includes admin_role.permissions, update_shops_permission
+ end
+
+ test "should load from fixtures" do
+ assert Role.count > 0
+
+ admin_role = Role.find_by(tag: "admin")
+ assert_not_nil admin_role
+ assert_equal "admin", admin_role.name
+ end
+
+ test "admin role should have all permissions" do
+ admin_role = Role.find_by(tag: "admin")
+ assert admin_role.permissions.count > 0
+ assert_includes admin_role.permissions.pluck(:tag), "update_shops"
+ assert_includes admin_role.permissions.pluck(:tag), "invitation"
+ end
+
+ test "guest role should have minimal permissions" do
+ guest_role = Role.find_by(tag: "guest")
+ assert guest_role.permissions.count > 0
+
+ read_data_permission = Permission.find_by(tag: "read_data")
+ assert_includes guest_role.permissions, read_data_permission
+ end
+
+ test "roles should be ordered by position" do
+ admin = Role.find_by(tag: "admin")
+ guest = Role.find_by(tag: "guest")
+
+ assert admin.position < guest.position
+ end
+end
diff --git a/test/models/roles_permission_test.rb b/test/models/roles_permission_test.rb
new file mode 100644
index 0000000..5b77936
--- /dev/null
+++ b/test/models/roles_permission_test.rb
@@ -0,0 +1,67 @@
+require "test_helper"
+
+class RolesPermissionTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ role = Role.first
+ permission = Permission.first
+
+ roles_permission = RolesPermission.new(
+ role: role,
+ permission: permission
+ )
+
+ assert roles_permission.valid?
+ end
+
+ test "should belong to role" do
+ roles_permission = RolesPermission.first
+ assert_respond_to roles_permission, :role
+ assert_instance_of Role, roles_permission.role
+ end
+
+ test "should belong to permission" do
+ roles_permission = RolesPermission.first
+ assert_respond_to roles_permission, :permission
+ assert_instance_of Permission, roles_permission.permission
+ end
+
+ test "should require role" do
+ permission = Permission.first
+ roles_permission = RolesPermission.new(permission: permission)
+
+ assert_not roles_permission.valid?
+ assert_includes roles_permission.errors[:role], "must exist"
+ end
+
+ test "should require permission" do
+ role = Role.first
+ roles_permission = RolesPermission.new(role: role)
+
+ assert_not roles_permission.valid?
+ assert_includes roles_permission.errors[:permission], "must exist"
+ end
+
+ test "should load from fixtures" do
+ assert RolesPermission.count > 0
+
+ admin_role = Role.find_by(tag: "admin")
+ update_shops = Permission.find_by(tag: "update_shops")
+
+ roles_permission = RolesPermission.find_by(
+ role: admin_role,
+ permission: update_shops
+ )
+
+ assert_not_nil roles_permission
+ end
+
+ test "should create association between role and permission" do
+ role = Role.create!(name: "Test Role", tag: "test_role_assoc", position: 100)
+ permission = Permission.create!(name: "Test Permission", tag: "test_perm_assoc", position: 100)
+
+ RolesPermission.create!(role: role, permission: permission)
+
+ assert_includes role.permissions, permission
+ assert_includes permission.roles, role
+ end
+end
diff --git a/test/models/shop_test.rb b/test/models/shop_test.rb
new file mode 100644
index 0000000..f2469c5
--- /dev/null
+++ b/test/models/shop_test.rb
@@ -0,0 +1,115 @@
+require "test_helper"
+
+class ShopTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ end
+
+ test "should be valid with valid attributes" do
+ shop = @account.shops.new(name: "Test Shop", created_by: @shopkeeper)
+ assert shop.valid?
+ end
+
+ test "should require name" do
+ shop = @account.shops.new(created_by: @shopkeeper)
+ assert_not shop.valid?
+ assert_includes shop.errors[:name], "can't be blank"
+ end
+
+ test "should belong to account" do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ assert_equal @account, shop.account
+ end
+
+ test "should belong to created_by shopkeeper" do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ assert_equal @shopkeeper, shop.created_by
+ end
+
+ test "should create default item tags after creation" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ expected_count = ConfigSettings.item_tag.default_count
+ assert_equal expected_count, shop.item_tags.count
+ end
+ end
+
+ test "default item tags should have correct queue numbers" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ item_tags = shop.item_tags.sorted
+
+ # Queue numbers are formatted based on ConfigSettings.item_tag.default_queue_number_length
+ # which defaults to 4, making the format "A001" not "A01"
+ assert_equal "A001", item_tags.first.queue_number
+ assert item_tags.all? { |tag| tag.queue_number.start_with?("A") }
+ end
+ end
+
+ test "should not create default item tags if item tags already exist" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ initial_count = shop.item_tags.count
+
+ # Manually trigger the callback
+ shop.send(:create_default_item_tags!)
+
+ assert_equal initial_count, shop.item_tags.reload.count
+ end
+ end
+
+ test "should destroy associated item tags" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ item_tag_count = shop.item_tags.count
+
+ assert_difference "ItemTag.count", -item_tag_count do
+ shop.destroy
+ end
+ end
+ end
+
+ test "latest_completed_item_tag returns most recently completed tag" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ item_tag1 = shop.item_tags.first
+ item_tag2 = shop.item_tags.second
+
+ item_tag1.complete_tag!(@shopkeeper)
+ sleep 0.01 # Ensure different timestamps
+ item_tag2.complete_tag!(@shopkeeper)
+
+ assert_equal item_tag2, shop.latest_completed_item_tag
+ end
+ end
+
+ test "latest_completed_item_tag returns nil if no completed tags" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ assert_nil shop.latest_completed_item_tag
+ end
+ end
+
+ test "reset! resets all item tags" do
+ ActsAsTenant.with_tenant(@account) do
+ shop = @account.shops.create!(name: "Test Shop", created_by: @shopkeeper)
+ item_tag1 = shop.item_tags.first
+ item_tag2 = shop.item_tags.second
+
+ item_tag1.complete_tag!(@shopkeeper)
+ item_tag2.complete_tag!(@shopkeeper)
+
+ assert item_tag1.completed?
+ assert item_tag2.completed?
+
+ shop.reset!
+
+ assert item_tag1.reload.idled?
+ assert item_tag2.reload.idled?
+ assert_nil item_tag1.completed_at
+ assert_nil item_tag2.completed_at
+ end
+ end
+end
diff --git a/test/models/shopkeeper_test.rb b/test/models/shopkeeper_test.rb
new file mode 100644
index 0000000..9d525e5
--- /dev/null
+++ b/test/models/shopkeeper_test.rb
@@ -0,0 +1,230 @@
+require "test_helper"
+
+class ShopkeeperTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ shopkeeper = Shopkeeper.new(
+ name: "Test User",
+ email: "test@example.com",
+ password: "password123",
+ current_platform: "ios",
+ confirmed_privacy_version: "1.0.0",
+ confirmed_terms_version: "1.0.0"
+ )
+ assert shopkeeper.valid?
+ end
+
+ test "should require name" do
+ shopkeeper = Shopkeeper.new(
+ email: "test@example.com",
+ password: "password123",
+ current_platform: "ios"
+ )
+ assert_not shopkeeper.valid?
+ assert_includes shopkeeper.errors[:name], "can't be blank"
+ end
+
+ test "should require email" do
+ shopkeeper = Shopkeeper.new(
+ name: "Test User",
+ password: "password123",
+ current_platform: "ios"
+ )
+ assert_not shopkeeper.valid?
+ assert_includes shopkeeper.errors[:email], "can't be blank"
+ end
+
+ test "should require current_platform" do
+ shopkeeper = Shopkeeper.new(
+ name: "Test User",
+ email: "test@example.com",
+ password: "password123"
+ )
+ assert_not shopkeeper.valid?
+ assert shopkeeper.errors[:current_platform].present?
+ end
+
+ test "should validate current_platform inclusion" do
+ shopkeeper = Shopkeeper.new(
+ name: "Test User",
+ email: "test@example.com",
+ password: "password123",
+ current_platform: "windows"
+ )
+ assert_not shopkeeper.valid?
+ assert_includes shopkeeper.errors[:current_platform], "is not included in the list"
+ end
+
+ test "should accept ios as current_platform" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.current_platform = "ios"
+ assert shopkeeper.valid?
+ end
+
+ test "should accept android as current_platform" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.current_platform = "android"
+ assert shopkeeper.valid?
+ end
+
+ test "android scope returns android shopkeepers" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.update!(current_platform: "android")
+
+ assert_includes Shopkeeper.android, shopkeeper
+ end
+
+ test "ios scope returns ios shopkeepers" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.update!(current_platform: "ios")
+
+ assert_includes Shopkeeper.ios, shopkeeper
+ end
+
+ test "should have many shops through accounts" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+ account = shopkeeper.accounts.first
+
+ assert_equal account.shops, shopkeeper.shops
+ end
+
+ test "should have many item_tags through shops" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+ account = shopkeeper.accounts.first
+ shop = account.shops.first
+
+ ActsAsTenant.with_tenant(account) do
+ assert_equal shop.item_tags.to_a, shopkeeper.item_tags.to_a
+ end
+ end
+
+ test "should have many created_shops" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+ shop = shopkeeper.created_shops.first
+
+ assert_equal shopkeeper, shop.created_by
+ end
+
+ test "create_default_account creates personal account" do
+ shopkeeper = Shopkeeper.create!(
+ name: "Test User",
+ email: "newuser@example.com",
+ password: "password123",
+ current_platform: "ios",
+ confirmed_privacy_version: "1.0.0",
+ confirmed_terms_version: "1.0.0"
+ )
+
+ assert_equal 1, shopkeeper.accounts.count
+ assert shopkeeper.personal_account.present?
+ assert shopkeeper.personal_account.personal?
+ assert_equal shopkeeper.name, shopkeeper.personal_account.name
+ end
+
+ test "create_default_account creates accounts_shopkeeper with admin role" do
+ shopkeeper = Shopkeeper.create!(
+ name: "Test User",
+ email: "newuser2@example.com",
+ password: "password123",
+ current_platform: "ios",
+ confirmed_privacy_version: "1.0.0",
+ confirmed_terms_version: "1.0.0"
+ )
+
+ accounts_shopkeeper = shopkeeper.accounts_shopkeepers.first
+ assert accounts_shopkeeper.admin?
+ end
+
+ test "create_default_account does not create account if name is blank" do
+ # Skip validations to create shopkeeper without name
+ shopkeeper = Shopkeeper.new(
+ email: "noname@example.com",
+ password: "password123",
+ current_platform: "ios"
+ )
+ shopkeeper.save(validate: false)
+
+ assert_equal 0, shopkeeper.accounts.count
+ end
+
+ test "create_default_account returns existing account if already present" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+ first_account = shopkeeper.accounts.first
+
+ result = shopkeeper.create_default_account
+
+ assert_equal first_account, result
+ assert_equal 1, shopkeeper.accounts.count
+ end
+
+ test "sync_personal_account_name updates personal account name when shopkeeper name changes" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+ personal_account = shopkeeper.personal_account
+
+ shopkeeper.update!(name: "Updated Name")
+
+ assert_equal "Updated Name", personal_account.reload.name
+ end
+
+ test "sync_personal_account_name creates personal account if missing when name is updated" do
+ # Create shopkeeper without personal account
+ shopkeeper = Shopkeeper.new(
+ email: "test3@example.com",
+ password: "password123",
+ current_platform: "ios"
+ )
+ shopkeeper.save(validate: false)
+
+ assert_nil shopkeeper.personal_account
+
+ shopkeeper.update!(name: "New Name")
+
+ assert_not_nil shopkeeper.reload.personal_account
+ assert_equal "New Name", shopkeeper.personal_account.name
+ end
+
+ test "should nullify accounts_invitations invited_by on destroy" do
+ shopkeeper = shopkeepers(:one)
+ other_shopkeeper = shopkeepers(:two)
+ other_shopkeeper.create_default_account
+ account = other_shopkeeper.accounts.first
+
+ # Create invitation invited by shopkeeper on another account
+ invitation = AccountsInvitation.create!(
+ account: account,
+ invited_by: shopkeeper,
+ name: "Invited User",
+ email: "invited@example.com",
+ junior_member: true
+ )
+
+ shopkeeper.destroy
+
+ # Invitation should still exist (on other_shopkeeper's account)
+ # but invited_by_id should be nullified
+ assert AccountsInvitation.exists?(invitation.id)
+ assert_nil invitation.reload.invited_by_id
+ end
+
+ test "should destroy associated accounts_shopkeepers" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+
+ assert_difference "AccountsShopkeeper.count", -1 do
+ shopkeeper.destroy
+ end
+ end
+
+ test "should destroy associated owned_accounts" do
+ shopkeeper = shopkeepers(:one)
+ shopkeeper.create_default_account
+
+ assert_difference "Account.count", -1 do
+ shopkeeper.destroy
+ end
+ end
+end
diff --git a/test/models/terms_version_test.rb b/test/models/terms_version_test.rb
new file mode 100644
index 0000000..4b97685
--- /dev/null
+++ b/test/models/terms_version_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+
+class TermsVersionTest < ActiveSupport::TestCase
+ test "should be valid with valid attributes" do
+ terms_version = TermsVersion.new(
+ version: 2,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Test Terms Version",
+ description: "Test Description"
+ )
+ assert terms_version.valid?
+ end
+
+ test "should have current_type enum" do
+ terms_version = TermsVersion.new(
+ version: 2,
+ current_type: :current,
+ published_at: Date.today
+ )
+
+ assert terms_version.current?
+ assert_not terms_version.uncurrent?
+
+ terms_version.current_type = :uncurrent
+ assert terms_version.uncurrent?
+ assert_not terms_version.current?
+ end
+
+ test "current_version should return latest current version" do
+ version = TermsVersion.current_version
+ assert_not_nil version
+ assert_kind_of Integer, version
+ end
+
+ test "current_version should return highest version number" do
+ TermsVersion.create!(
+ version: 10,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Version 10",
+ description: "Test"
+ )
+
+ TermsVersion.create!(
+ version: 15,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Version 15",
+ description: "Test"
+ )
+
+ assert_equal 15, TermsVersion.current_version
+ end
+
+ test "current_version should only return current versions" do
+ TermsVersion.create!(
+ version: 20,
+ current_type: :uncurrent,
+ published_at: Date.today,
+ title: "Uncurrent",
+ description: "Test"
+ )
+
+ TermsVersion.create!(
+ version: 5,
+ current_type: :current,
+ published_at: Date.today,
+ title: "Current",
+ description: "Test"
+ )
+
+ assert_equal 5, TermsVersion.current_version
+ end
+
+ test "should load from fixtures" do
+ assert TermsVersion.count > 0
+
+ terms_version = TermsVersion.first
+ assert_not_nil terms_version
+ assert_not_nil terms_version.version
+ assert_not_nil terms_version.published_at
+ end
+end
diff --git a/test/policies/api/shopkeeper/base_policy_test.rb b/test/policies/api/shopkeeper/base_policy_test.rb
new file mode 100644
index 0000000..684f8f7
--- /dev/null
+++ b/test/policies/api/shopkeeper/base_policy_test.rb
@@ -0,0 +1,74 @@
+require "test_helper"
+
+class Api::Shopkeeper::BasePolicyTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @accounts_shopkeeper = @account.accounts_shopkeepers.first
+ @record = @account
+ end
+
+ test "initialize raises error when accounts_shopkeeper is nil" do
+ assert_raises Pundit::NotAuthorizedError do
+ Api::Shopkeeper::BasePolicy.new(nil, @record)
+ end
+ end
+
+ test "initialize succeeds when accounts_shopkeeper is present" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_equal @accounts_shopkeeper, policy.accounts_shopkeeper
+ assert_equal @record, policy.record
+ end
+
+ test "index? returns false by default" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_not policy.index?
+ end
+
+ test "show? returns false by default" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_not policy.show?
+ end
+
+ test "create? returns false by default" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_not policy.create?
+ end
+
+ test "new? delegates to create?" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_equal policy.create?, policy.new?
+ end
+
+ test "update? returns false by default" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_not policy.update?
+ end
+
+ test "edit? delegates to update?" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_equal policy.update?, policy.edit?
+ end
+
+ test "destroy? returns false by default" do
+ policy = Api::Shopkeeper::BasePolicy.new(@accounts_shopkeeper, @record)
+ assert_not policy.destroy?
+ end
+
+ test "Scope raises error when accounts_shopkeeper is nil" do
+ assert_raises Pundit::NotAuthorizedError do
+ Api::Shopkeeper::BasePolicy::Scope.new(nil, Account)
+ end
+ end
+
+ test "Scope initialize succeeds when accounts_shopkeeper is present" do
+ scope = Api::Shopkeeper::BasePolicy::Scope.new(@accounts_shopkeeper, Account)
+ assert_not_nil scope
+ end
+
+ test "Scope resolve returns all records by default" do
+ scope = Api::Shopkeeper::BasePolicy::Scope.new(@accounts_shopkeeper, Account)
+ assert_equal Account.all, scope.resolve
+ end
+end
diff --git a/test/policies/api/shopkeeper/item_tag_policy_test.rb b/test/policies/api/shopkeeper/item_tag_policy_test.rb
new file mode 100644
index 0000000..ac22026
--- /dev/null
+++ b/test/policies/api/shopkeeper/item_tag_policy_test.rb
@@ -0,0 +1,143 @@
+require "test_helper"
+
+class Api::Shopkeeper::ItemTagPolicyTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @shop = @account.shops.first
+ @item_tag = @shop.item_tags.first
+ end
+
+ test "index? returns true for all users" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.index?
+ end
+
+ test "show? returns true for all users" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.show?
+ end
+
+ test "create? returns true for admin" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ accounts_shopkeeper.update!(admin: true)
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.create?
+ end
+
+ test "create? returns true for senior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_manager: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.create?
+ end
+
+ test "create? returns false for junior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_manager: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_not policy.create?
+ end
+
+ test "create? returns false for junior_member" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_not policy.create?
+ end
+
+ test "update? delegates to create?" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_equal policy.create?, policy.update?
+ end
+
+ test "destroy? delegates to create?" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_equal policy.create?, policy.destroy?
+ end
+
+ test "complete? returns true for admin" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ accounts_shopkeeper.update!(admin: true)
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.complete?
+ end
+
+ test "complete? returns true for senior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_manager: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.complete?
+ end
+
+ test "complete? returns true for junior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_manager: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.complete?
+ end
+
+ test "complete? returns true for senior_member" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_member: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.complete?
+ end
+
+ test "complete? returns true for junior_member" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert policy.complete?
+ end
+
+ test "complete? returns false for guest" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ guest: true
+ )
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_not policy.complete?
+ end
+
+ test "reset? delegates to complete?" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ItemTagPolicy.new(accounts_shopkeeper, @item_tag)
+ assert_equal policy.complete?, policy.reset?
+ end
+end
diff --git a/test/policies/api/shopkeeper/permission_policy_test.rb b/test/policies/api/shopkeeper/permission_policy_test.rb
new file mode 100644
index 0000000..cf72124
--- /dev/null
+++ b/test/policies/api/shopkeeper/permission_policy_test.rb
@@ -0,0 +1,28 @@
+require "test_helper"
+
+class Api::Shopkeeper::PermissionPolicyTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @accounts_shopkeeper = @account.accounts_shopkeepers.first
+ @permission = Permission.first
+ end
+
+ test "index? returns true for all users" do
+ policy = Api::Shopkeeper::PermissionPolicy.new(@accounts_shopkeeper, @permission)
+ assert policy.index?
+ end
+
+ test "index? returns true for guest" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ guest: true
+ )
+
+ policy = Api::Shopkeeper::PermissionPolicy.new(accounts_shopkeeper, @permission)
+ assert policy.index?
+ end
+end
diff --git a/test/policies/api/shopkeeper/shop_policy_test.rb b/test/policies/api/shopkeeper/shop_policy_test.rb
new file mode 100644
index 0000000..0e5ebb0
--- /dev/null
+++ b/test/policies/api/shopkeeper/shop_policy_test.rb
@@ -0,0 +1,137 @@
+require "test_helper"
+
+class Api::Shopkeeper::ShopPolicyTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ @shop = @account.shops.first
+ end
+
+ test "index? returns true for all users" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.index?
+ end
+
+ test "show? returns true for all users" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.show?
+ end
+
+ test "create? returns true for account owner" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ assert accounts_shopkeeper.account_owner?
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.create?
+ end
+
+ test "create? returns false for non-owner" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ admin: true
+ )
+ assert_not accounts_shopkeeper.account_owner?
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert_not policy.create?
+ end
+
+ test "update? returns true for admin" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ accounts_shopkeeper.update!(admin: true)
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.update?
+ end
+
+ test "update? returns false for non-admin" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_manager: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert_not policy.update?
+ end
+
+ test "destroy? delegates to create?" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert_equal policy.create?, policy.destroy?
+ end
+
+ test "reset? returns true for admin" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.first
+ accounts_shopkeeper.update!(admin: true)
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.reset?
+ end
+
+ test "reset? returns true for senior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_manager: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.reset?
+ end
+
+ test "reset? returns true for junior_manager" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_manager: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.reset?
+ end
+
+ test "reset? returns true for senior_member" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ senior_member: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert policy.reset?
+ end
+
+ test "reset? returns false for junior_member" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert_not policy.reset?
+ end
+
+ test "reset? returns false for guest" do
+ other_shopkeeper = shopkeepers(:two)
+ accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ guest: true
+ )
+
+ policy = Api::Shopkeeper::ShopPolicy.new(accounts_shopkeeper, @shop)
+ assert_not policy.reset?
+ end
+end
diff --git a/test/serializers/account_serializer_test.rb b/test/serializers/account_serializer_test.rb
new file mode 100644
index 0000000..ee86f71
--- /dev/null
+++ b/test/serializers/account_serializer_test.rb
@@ -0,0 +1,126 @@
+require "test_helper"
+
+class AccountSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+ end
+
+ test "should serialize basic attributes" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @account.name, attributes[:name]
+ assert_equal @account.owner_id, attributes[:owner_id]
+ assert_equal @account.personal, attributes[:personal]
+ end
+
+ test "should serialize owner_name attribute" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @account.owner.name, attributes[:owner_name]
+ end
+
+ test "should serialize accounts_shopkeepers_count" do
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: shopkeepers(:two),
+ junior_member: true
+ )
+
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal 2, attributes[:accounts_shopkeepers_count]
+ end
+
+ test "should serialize accounts_invitations_count" do
+ AccountsInvitation.create!(
+ account: @account,
+ name: "Test User",
+ email: "test@example.com",
+ junior_member: true
+ )
+
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal 1, attributes[:accounts_invitations_count]
+ end
+
+ test "should serialize shops_count" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal 1, attributes[:shops_count]
+ end
+
+ test "should serialize is_admin attribute with current_shopkeeper param" do
+ accounts_shopkeeper = @account.accounts_shopkeepers.find_by(shopkeeper: @shopkeeper)
+ accounts_shopkeeper.update!(admin: true)
+
+ serializer = AccountSerializer.new(@account, params: {current_shopkeeper: @shopkeeper})
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:is_admin]
+ end
+
+ test "should serialize is_admin as false for non-admin" do
+ other_shopkeeper = shopkeepers(:two)
+ AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: other_shopkeeper,
+ junior_member: true
+ )
+
+ serializer = AccountSerializer.new(@account, params: {current_shopkeeper: other_shopkeeper})
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_not attributes[:is_admin]
+ end
+
+ test "should include owner relationship" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:owner]
+ assert_equal @account.owner_id, serialized[:data][:relationships][:owner][:data][:id]
+ end
+
+ test "should include accounts_shopkeepers relationship" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:accounts_shopkeepers]
+ end
+
+ test "should include accounts_invitations relationship" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:accounts_invitations]
+ end
+
+ test "should have correct type" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ assert_equal "account", serialized[:data][:type].to_s.to_s
+ end
+
+ test "should have correct id" do
+ serializer = AccountSerializer.new(@account)
+ serialized = serializer.serializable_hash
+
+ assert_equal @account.id, serialized[:data][:id]
+ end
+end
diff --git a/test/serializers/accounts_invitation_serializer_test.rb b/test/serializers/accounts_invitation_serializer_test.rb
new file mode 100644
index 0000000..9f5cfd1
--- /dev/null
+++ b/test/serializers/accounts_invitation_serializer_test.rb
@@ -0,0 +1,93 @@
+require "test_helper"
+
+class AccountsInvitationSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ @invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Invited User",
+ email: "invited@example.com",
+ invited_by: @shopkeeper,
+ admin: true
+ )
+ end
+
+ test "should serialize basic attributes" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @invitation.account_id, attributes[:account_id]
+ assert_equal @invitation.invited_by_id, attributes[:invited_by_id]
+ assert_equal @invitation.name, attributes[:name]
+ assert_equal @invitation.token, attributes[:token]
+ assert_equal @invitation.email, attributes[:email]
+ end
+
+ test "should serialize all role attributes" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ AccountsShopkeeper::ROLES.each do |role|
+ assert attributes.key?(role)
+ end
+ end
+
+ test "should serialize admin role" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:admin]
+ end
+
+ test "should serialize junior_member role" do
+ invitation = AccountsInvitation.create!(
+ account: @account,
+ name: "Junior Member",
+ email: "junior@example.com",
+ junior_member: true
+ )
+
+ serializer = AccountsInvitationSerializer.new(invitation)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:junior_member]
+ assert_not attributes[:admin]
+ end
+
+ test "should include account relationship" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:account]
+ assert_equal @invitation.account_id, serialized[:data][:relationships][:account][:data][:id]
+ end
+
+ test "should include invited_by relationship" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:invited_by]
+ assert_equal @invitation.invited_by_id, serialized[:data][:relationships][:invited_by][:data][:id]
+ end
+
+ test "should have correct type" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ assert_equal "accounts_invitation", serialized[:data][:type].to_s
+ end
+
+ test "should have correct id" do
+ serializer = AccountsInvitationSerializer.new(@invitation)
+ serialized = serializer.serializable_hash
+
+ assert_equal @invitation.id, serialized[:data][:id]
+ end
+end
diff --git a/test/serializers/accounts_shopkeeper_serializer_test.rb b/test/serializers/accounts_shopkeeper_serializer_test.rb
new file mode 100644
index 0000000..36bbc86
--- /dev/null
+++ b/test/serializers/accounts_shopkeeper_serializer_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+
+class AccountsShopkeeperSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ @accounts_shopkeeper = AccountsShopkeeper.create!(
+ account: @account,
+ shopkeeper: shopkeepers(:two),
+ senior_manager: true
+ )
+ end
+
+ test "should serialize basic attributes" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @accounts_shopkeeper.account_id, attributes[:account_id]
+ assert_equal @accounts_shopkeeper.shopkeeper_id, attributes[:shopkeeper_id]
+ end
+
+ test "should serialize all role attributes" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ AccountsShopkeeper::ROLES.each do |role|
+ assert attributes.key?(role)
+ end
+ end
+
+ test "should serialize senior_manager role" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:senior_manager]
+ assert_not attributes[:admin]
+ end
+
+ test "should serialize admin role" do
+ admin_as = @account.accounts_shopkeepers.find_by(shopkeeper: @shopkeeper)
+ admin_as.update!(admin: true)
+
+ serializer = AccountsShopkeeperSerializer.new(admin_as)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:admin]
+ end
+
+ test "should include account relationship" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:account]
+ assert_equal @accounts_shopkeeper.account_id, serialized[:data][:relationships][:account][:data][:id]
+ end
+
+ test "should include shopkeeper relationship" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:shopkeeper]
+ assert_equal @accounts_shopkeeper.shopkeeper_id, serialized[:data][:relationships][:shopkeeper][:data][:id]
+ end
+
+ test "should have correct type" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal "accounts_shopkeeper", serialized[:data][:type].to_s
+ end
+
+ test "should have correct id" do
+ serializer = AccountsShopkeeperSerializer.new(@accounts_shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal @accounts_shopkeeper.id, serialized[:data][:id]
+ end
+end
diff --git a/test/serializers/item_tag_serializer_test.rb b/test/serializers/item_tag_serializer_test.rb
new file mode 100644
index 0000000..6edc85b
--- /dev/null
+++ b/test/serializers/item_tag_serializer_test.rb
@@ -0,0 +1,105 @@
+require "test_helper"
+
+class ItemTagSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ ActsAsTenant.with_tenant(@account) do
+ @shop = @account.shops.first
+ @item_tag = @shop.item_tags.first
+ end
+ end
+
+ test "should serialize basic attributes" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @item_tag.shop_id, attributes[:shop_id]
+ assert_equal @item_tag.queue_number, attributes[:queue_number]
+ assert_equal @item_tag.state, attributes[:state]
+ assert_equal @item_tag.scan_state, attributes[:scan_state]
+ assert_equal @item_tag.customer_read_at, attributes[:customer_read_at]
+ assert_equal @item_tag.completed_at, attributes[:completed_at]
+ assert_equal @item_tag.already_completed, attributes[:already_completed]
+ end
+ end
+
+ test "should serialize timestamps" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:created_at]
+ assert attributes[:updated_at]
+ end
+ end
+
+ test "should serialize shop_name attribute" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shop.name, attributes[:shop_name]
+ end
+ end
+
+ test "should include shop relationship" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:shop]
+ assert_equal @item_tag.shop_id, serialized[:data][:relationships][:shop][:data][:id]
+ end
+ end
+
+ test "should have correct type" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ assert_equal "item_tag", serialized[:data][:type].to_s
+ end
+ end
+
+ test "should have correct id" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ assert_equal @item_tag.id, serialized[:data][:id]
+ end
+ end
+
+ test "should serialize completed item tag" do
+ ActsAsTenant.with_tenant(@account) do
+ @item_tag.complete_tag!(@shopkeeper)
+
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal "completed", attributes[:state]
+ assert attributes[:completed_at]
+ assert_not_nil attributes[:already_completed]
+ end
+ end
+
+ test "should serialize scanned item tag" do
+ ActsAsTenant.with_tenant(@account) do
+ @item_tag.scan_tag!
+
+ serializer = ItemTagSerializer.new(@item_tag)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal "scanned", attributes[:scan_state]
+ end
+ end
+end
diff --git a/test/serializers/permission_serializer_test.rb b/test/serializers/permission_serializer_test.rb
new file mode 100644
index 0000000..64bd067
--- /dev/null
+++ b/test/serializers/permission_serializer_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+
+class PermissionSerializerTest < ActiveSupport::TestCase
+ def setup
+ @permission = Permission.first
+ end
+
+ test "should serialize basic attributes" do
+ serializer = PermissionSerializer.new(@permission)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @permission.name, attributes[:name]
+ assert_equal @permission.tag, attributes[:tag]
+ end
+
+ test "should serialize timestamps" do
+ serializer = PermissionSerializer.new(@permission)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert attributes[:created_at]
+ assert attributes[:updated_at]
+ end
+
+ test "should have correct type" do
+ serializer = PermissionSerializer.new(@permission)
+ serialized = serializer.serializable_hash
+
+ assert_equal "permission", serialized[:data][:type].to_s
+ end
+
+ test "should have correct id" do
+ serializer = PermissionSerializer.new(@permission)
+ serialized = serializer.serializable_hash
+
+ assert_equal @permission.id, serialized[:data][:id]
+ end
+
+ test "should not include position attribute" do
+ serializer = PermissionSerializer.new(@permission)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_nil attributes[:position]
+ end
+end
diff --git a/test/serializers/shop_serializer_test.rb b/test/serializers/shop_serializer_test.rb
new file mode 100644
index 0000000..af5eb35
--- /dev/null
+++ b/test/serializers/shop_serializer_test.rb
@@ -0,0 +1,101 @@
+require "test_helper"
+
+class ShopSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @account = @shopkeeper.accounts.first
+
+ ActsAsTenant.with_tenant(@account) do
+ @shop = @account.shops.first
+ end
+ end
+
+ test "should serialize basic attributes" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shop.name, attributes[:name]
+ assert_equal @shop.description, attributes[:description]
+ assert_equal @shop.time_zone, attributes[:time_zone]
+ end
+ end
+
+ test "should serialize item_tags_count" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shop.item_tags.size, attributes[:item_tags_count]
+ end
+ end
+
+ test "should serialize scanned_item_tags_count" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.scan_tag!
+
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal 1, attributes[:scanned_item_tags_count]
+ end
+ end
+
+ test "should serialize completed_item_tags_count" do
+ ActsAsTenant.with_tenant(@account) do
+ item_tag = @shop.item_tags.first
+ item_tag.complete_tag!(@shopkeeper)
+
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal 1, attributes[:completed_item_tags_count]
+ end
+ end
+
+ test "should serialize display_shop_server_path" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_includes attributes[:display_shop_server_path], "/display/shops/"
+ assert_includes attributes[:display_shop_server_path], @shop.id
+ assert_includes attributes[:display_shop_server_path], "type=server"
+ end
+ end
+
+ test "should include account relationship" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ assert serialized[:data][:relationships][:account]
+ assert_equal @shop.account_id, serialized[:data][:relationships][:account][:data][:id]
+ end
+ end
+
+ test "should have correct type" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ assert_equal "shop", serialized[:data][:type].to_s
+ end
+ end
+
+ test "should have correct id" do
+ ActsAsTenant.with_tenant(@account) do
+ serializer = ShopSerializer.new(@shop)
+ serialized = serializer.serializable_hash
+
+ assert_equal @shop.id, serialized[:data][:id]
+ end
+ end
+end
diff --git a/test/serializers/shopkeeper_serializer_test.rb b/test/serializers/shopkeeper_serializer_test.rb
new file mode 100644
index 0000000..c041b87
--- /dev/null
+++ b/test/serializers/shopkeeper_serializer_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class ShopkeeperSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ end
+
+ test "should serialize basic attributes" do
+ serializer = ShopkeeperSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.email, attributes[:email]
+ assert_equal @shopkeeper.name, attributes[:name]
+ assert_equal @shopkeeper.time_zone, attributes[:time_zone]
+ assert_equal @shopkeeper.locale, attributes[:locale]
+ end
+
+ test "should have correct type" do
+ serializer = ShopkeeperSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal "shopkeeper", serialized[:data][:type].to_s
+ end
+
+ test "should have correct id" do
+ serializer = ShopkeeperSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal @shopkeeper.id, serialized[:data][:id]
+ end
+
+ test "should not include sensitive attributes" do
+ serializer = ShopkeeperSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_nil attributes[:encrypted_password]
+ assert_nil attributes[:uid]
+ assert_nil attributes[:tokens]
+ end
+end
diff --git a/test/serializers/shopkeeper_sign_in_serializer_test.rb b/test/serializers/shopkeeper_sign_in_serializer_test.rb
new file mode 100644
index 0000000..e5a5279
--- /dev/null
+++ b/test/serializers/shopkeeper_sign_in_serializer_test.rb
@@ -0,0 +1,80 @@
+require "test_helper"
+
+class ShopkeeperSignInSerializerTest < ActiveSupport::TestCase
+ def setup
+ @shopkeeper = shopkeepers(:one)
+ @shopkeeper.create_default_account
+ @shopkeeper.token = "test_token"
+ @shopkeeper.client = "test_client"
+ @shopkeeper.expiry = 123456
+ @shopkeeper.account_id = @shopkeeper.personal_account.id
+ end
+
+ test "should serialize basic attributes" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.email, attributes[:email]
+ assert_equal @shopkeeper.name, attributes[:name]
+ assert_equal @shopkeeper.uid, attributes[:uid]
+ assert_equal @shopkeeper.time_zone, attributes[:time_zone]
+ assert_equal @shopkeeper.locale, attributes[:locale]
+ end
+
+ test "should serialize authentication tokens" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal "test_token", attributes[:token]
+ assert_equal "test_client", attributes[:client]
+ assert_equal 123456.to_s, attributes[:expiry].to_s
+ end
+
+ test "should serialize account_id" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.personal_account.id, attributes[:account_id]
+ end
+
+ test "should serialize personal_account_id" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.personal_account.id, attributes[:personal_account_id]
+ end
+
+ test "should serialize account_owner_id" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.personal_account.owner_id, attributes[:account_owner_id]
+ end
+
+ test "should serialize account_name" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ attributes = serialized[:data][:attributes]
+ assert_equal @shopkeeper.personal_account.name, attributes[:account_name]
+ end
+
+ test "should have correct type" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal "shopkeeper_sign_in", serialized[:data][:type].to_s
+ end
+
+ test "should have correct id" do
+ serializer = ShopkeeperSignInSerializer.new(@shopkeeper)
+ serialized = serializer.serializable_hash
+
+ assert_equal @shopkeeper.id, serialized[:data][:id]
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d5766f8..4d78042 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,7 +1,6 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
-require "minitest/mock"
require "webmock/minitest"
# Uncomment to view full stack trace in tests
diff --git a/yarn.lock b/yarn.lock
index c722365..ccdfa40 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -353,9 +353,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663:
- version "1.0.30001667"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz#99fc5ea0d9c6e96897a104a8352604378377f949"
- integrity sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==
+ version "1.0.30001762"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz"
+ integrity sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==
chokidar@^3.5.3:
version "3.6.0"
@@ -907,16 +907,8 @@ spark-md5@^3.0.1:
resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
+ name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -934,14 +926,8 @@ string-width@^5.0.1, string-width@^5.1.2:
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==