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 + + + + + + + + + + + N + + + + + 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==