diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..e8d7b0dab --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,126 @@ +version: 2.1 + +orbs: + browser-tools: circleci/browser-tools@1.5.2 + node: circleci/node@7.1.0 + +jobs: + build: + parallelism: 1 + docker: + - image: cimg/ruby:4.0.0-browsers + environment: + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + BUNDLE_PATH: vendor/bundle + PGHOST: 127.0.0.1 + PGUSER: postgres + RACK_ENV: test + RAILS_ENV: test + - image: cimg/postgres:10.18 + environment: + POSTGRES_USER: postgres + POSTGRES_DB: app_test + POSTGRES_PASSWORD: + + steps: + - checkout + - browser-tools/install-browser-tools: + install-chrome: false + install-chromedriver: false + + # - node/install: + # node-version: 16.13.1 + # install-yarn: true + + - run: + name: Which bundler? + command: bundle -v + + # https://circleci.com/docs/2.0/caching/ + - restore_cache: + keys: + - bundle-v1-{{ checksum "Gemfile.lock" }} + - bundle-v1- + + - run: # Install Ruby dependencies + name: Bundle Install + command: | + bundle config set --local frozen 'true' + bundle install + bundle clean + + - save_cache: + key: bundle-v1-{{ checksum "Gemfile.lock" }} + paths: + - vendor/bundle + + # - restore_cache: + # keys: + # - yarn-{{ checksum "yarn.lock" }} + # - yarn- + + # - run: + # name: Yarn Install + # command: yarn install --cache-folder ~/.cache/yarn + + # - save_cache: + # key: yarn-{{ checksum "yarn.lock" }} + # paths: + # - ~/.cache/yarn + + - run: + name: Wait for DB + command: dockerize -wait tcp://localhost:5432 -timeout 1m + + - run: + name: Database setup + command: bundle exec rake db:create db:schema:load --trace + + # - run: + # name: Brakeman + # command: bundle exec brakeman + + # - run: + # name: Stylelint + # command: yarn stylelint + + - run: + name: Rubocop + command: bundle exec rubocop + + - run: + name: Run rspec in parallel + command: | + bundle exec rspec --exclude-pattern "spec/system/*_spec.rb" + # bundle exec rspec --profile 10 \ + # --format RspecJunitFormatter \ + # --out test_results/rspec.xml \ + # --format progress \ + # $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + + - store_test_results: # https://circleci.com/docs/2.0/collect-test-data/ + path: test_results + + - run: + name: Run system tests + command: | + COVERAGE=false bundle exec rspec spec/system/ + +workflows: + build: + jobs: + - build + + # https://circleci.com/docs/2.0/workflows/#nightly-example + # https://circleci.com/docs/2.0/configuration-reference/#filters-1 + repeat: + jobs: + - build + triggers: + - schedule: + cron: "0,20,40 * * * *" + filters: + branches: + only: + - /.*ci-repeat.*/ diff --git a/.dockerignore b/.dockerignore index 745841df6..4a2324570 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ .git/* .gitignore log/* -tmp/* diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..7ae8c1e97 --- /dev/null +++ b/.env.development @@ -0,0 +1,7 @@ +# you can use `rails secret` to generate this for production +SECRET_KEY_BASE=dev_secret + +# you can use `rails db:encryption:init` to generate these for production +ENCRYPTION_PRIMARY_KEY=dev_primary_key +ENCRYPTION_DETERMINISTIC_KEY=dev_deterministic_key +ENCRYPTION_KEY_DERIVATION_SALT=dev_derivation_salt diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..b6f983942 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +# you can use `rails secret` to generate this for production +SECRET_KEY_BASE=test_secret + +# you can use `rails db:encryption:init` to generate these for production +ENCRYPTION_PRIMARY_KEY=test_primary_key +ENCRYPTION_DETERMINISTIC_KEY=test_deterministic_key +ENCRYPTION_KEY_DERIVATION_SALT=test_derivation_salt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..31eeee0b6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..d916cd6ae --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: mockdeep diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..937450997 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,57 @@ +name: Build Docker image and push to Dockerhub + +on: + push: + pull_request: + workflow_dispatch: + +env: + IMAGE_NAME: stringerrss/stringer + +jobs: + build_docker: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract Docker sha tag + id: get-tag-sha + uses: docker/metadata-action@v4 + with: + images: ${{ env.IMAGE_NAME }} + tags: type=sha + + - name: Extract Docker latest tag + id: get-tag-latest + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: type=raw, value=latest + + - name: Log in to Docker Hub + if: ${{ github.ref_name == 'main' }} + id: login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.ref_name == 'main' }} + tags: | + ${{ steps.get-tag-latest.outputs.tags }} + ${{ steps.get-tag-sha.outputs.tags }} diff --git a/.gitignore b/.gitignore index e35bb576d..541965950 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,31 @@ -*.gem -*.rbc -.bundle -.config -.ruby-gemset -coverage -InstalledFiles -lib/bundler/man -pkg -rdoc -spec/reports -test/tmp -test/version_tmp -tmp -log +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' -# YARD artifacts -.yardoc -_yardoc -doc/ -bin/ +# Ignore bundler config. +/.bundle -db/*.sqlite -.DS_Store -.localeapp +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +/coverage +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key + +spec/examples.txt + +# Ignore local .env files +*.local +.env diff --git a/.rspec b/.rspec index cf6add7ea..a35c44f4b 100644 --- a/.rspec +++ b/.rspec @@ -1 +1 @@ ---colour \ No newline at end of file +--require rails_helper diff --git a/.rubocop.yml b/.rubocop.yml index a7403fc37..816628fa0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,31 +1,77 @@ inherit_from: .rubocop_todo.yml +inherit_mode: { merge: [Exclude] } + +plugins: + - rubocop-capybara + - rubocop-factory_bot + - rubocop-rails + - rubocop-rake + - rubocop-rspec + - rubocop-rspec_rails AllCops: + DisplayCopNames: true + EnabledByDefault: true Exclude: - 'db/schema.rb' - 'vendor/**/*' - TargetRubyVersion: 2.3 - -Metrics/LineLength: - Max: 120 - -Metrics/MethodLength: - Max: 15 -Style/Documentation: - Enabled: false - -Style/DoubleNegation: - Enabled: false - -Style/IfUnlessModifier: - MaxLineLength: 120 +Capybara/ClickLinkOrButtonStyle: { EnforcedStyle: link_or_button } +Layout/LineLength: { Max: 80, Exclude: [db/migrate/*.rb] } +Layout/RedundantLineBreak: { InspectBlocks: true } +Metrics/AbcSize: { Exclude: [db/migrate/*.rb], CountRepeatedAttributes: false } +Metrics/BlockLength: + Exclude: [config/**/*.rb, db/migrate/*.rb, spec/**/*.rb, db/seeds/**/*.rb] +Metrics/MethodLength: { Exclude: [db/migrate/*.rb] } +Rails/SkipsModelValidations: { AllowedMethods: [update_all] } +RSpec/DescribeClass: { Exclude: [spec/system/**/*] } +RSpec/MessageExpectation: + EnforcedStyle: expect + Exclude: [spec/support/matchers/**/*.rb] +RSpec/MessageSpies: { EnforcedStyle: receive } +RSpec/MultipleMemoizedHelpers: { AllowSubject: false, Max: 0 } +Style/ClassAndModuleChildren: { EnforcedStyle: compact } +Style/MethodCallWithArgsParentheses: + AllowedMethods: + - and + - to + - not_to + - describe + - require + - task + Exclude: + - db/**/*.rb +Style/StringLiterals: { EnforcedStyle: double_quotes } +Style/SymbolArray: { EnforcedStyle: brackets } +Style/WordArray: { EnforcedStyle: brackets } -Style/NumericLiterals: - Enabled: false +# want to enable these, but they don't work right when using `.rubocop_todo.yml` +Style/DocumentationMethod: { Enabled: false } +Style/Documentation: { Enabled: false } -Style/StringLiterals: - EnforcedStyle: double_quotes +################################################################################ +# +# Rules we don't want to enable +# +################################################################################ -Style/WhileUntilModifier: - MaxLineLength: 120 +Bundler/GemComment: { Enabled: false } +Bundler/GemVersion: { Enabled: false } +Capybara/AmbiguousClick: { Enabled: false } +Layout/SingleLineBlockChain: { Enabled: false } +Lint/ConstantResolution: { Enabled: false } +Rails/BulkChangeTable: { Enabled: false } +Rails/RedundantPresenceValidationOnBelongsTo: { Enabled: false } +RSpec/AlignLeftLetBrace: { Enabled: false } +RSpec/AlignRightLetBrace: { Enabled: false } +Rails/HasManyOrHasOneDependent: { Enabled: false } +RSpec/IndexedLet: { Enabled: false } +RSpec/StubbedMock: { Enabled: false } +Rails/SchemaComment: { Enabled: false } +Style/ConstantVisibility: { Enabled: false } +Style/Copyright: { Enabled: false } +Style/InlineComment: { Enabled: false } +Style/MissingElse: { Enabled: false } +Style/RequireOrder: { Enabled: false } +Style/SafeNavigation: { Enabled: false } +Style/StringHashKeys: { Enabled: false } diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 0e4ee7524..8badbab31 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,14 +1,282 @@ # This configuration was generated by -# `rubocop --auto-gen-config` -# on 2016-03-22 20:52:58 +0100 using RuboCop version 0.38.0. +# `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 400` +# on 2025-11-17 17:32:59 UTC using RuboCop version 1.81.7. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 163 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: when_needed, always -Style/FrozenStringLiteralComment: - Enabled: false +# Offense count: 3 +Capybara/NegationMatcherAfterVisit: + Exclude: + - 'spec/system/account_setup_spec.rb' + - 'spec/system/stories_index_spec.rb' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. +# SupportedHashRocketStyles: key, separator, table +# SupportedColonStyles: key, separator, table +# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit +Layout/HashAlignment: + Exclude: + - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb' + - 'db/migrate/20240314031221_create_good_job_labels_index.rb' + - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb' + +# Offense count: 19 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultilineFinalElement. +Layout/MultilineMethodArgumentLineBreaks: + Exclude: + - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb' + - 'db/migrate/20240314031221_create_good_job_labels_index.rb' + - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb' + +# Offense count: 10 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredClasses. +# AllowedMethods: ago, from_now, second, seconds, minute, minutes, hour, hours, day, days, week, weeks, fortnight, fortnights, in_milliseconds +# IgnoredClasses: Time, DateTime +Lint/NumberConversion: + Exclude: + - 'Rakefile' + - 'app/commands/fever_api/authentication.rb' + - 'app/commands/story/mark_group_as_read.rb' + - 'app/models/feed.rb' + - 'app/models/story.rb' + - 'app/repositories/story_repository.rb' + - 'spec/models/feed_spec.rb' + - 'spec/models/story_spec.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/CyclomaticComplexity: + Exclude: + - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb' + +# Offense count: 5 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'app/models/story.rb' + - 'app/repositories/story_repository.rb' + - 'app/utils/opml_parser.rb' + - 'app/utils/sample_story.rb' + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +Metrics/PerceivedComplexity: + Exclude: + - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb' + +# Offense count: 3 +# Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates. +# AllowedMethods: call +# WaywardPredicates: nonzero? +Naming/PredicateMethod: + Exclude: + - 'app/utils/sample_story.rb' + +# Offense count: 2 +# Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros, UseSorbetSigs. +# NamePrefix: is_, has_, have_, does_ +# ForbiddenPrefixes: is_, has_, have_, does_ +# AllowedMethods: is_a? +# MethodDefinitionMacros: define_method, define_singleton_method +Naming/PredicatePrefix: + Exclude: + - 'app/utils/sample_story.rb' + +# Offense count: 4 +RSpec/Be: + Exclude: + - 'spec/commands/feed/import_from_opml_spec.rb' + +# Offense count: 13 +# Configuration parameters: Prefixes, AllowedPatterns. +# Prefixes: when, with, without +RSpec/ContextWording: + Exclude: + - 'spec/commands/feed/create_spec.rb' + - 'spec/commands/feed/fetch_one_spec.rb' + - 'spec/commands/feed/find_new_stories_spec.rb' + - 'spec/commands/feed/import_from_opml_spec.rb' + - 'spec/integration/feed_importing_spec.rb' + +# Offense count: 1 +# Configuration parameters: IgnoredMetadata. +RSpec/DescribeClass: + Exclude: + - 'spec/integration/feed_importing_spec.rb' + +# Offense count: 35 +# Configuration parameters: Max, CountAsOne. +RSpec/ExampleLength: + Exclude: + - 'spec/commands/feed/create_spec.rb' + - 'spec/commands/feed/export_to_opml_spec.rb' + - 'spec/commands/fever_api/read_favicons_spec.rb' + - 'spec/commands/fever_api/read_feeds_groups_spec.rb' + - 'spec/commands/fever_api/read_items_spec.rb' + - 'spec/helpers/url_helpers_spec.rb' + - 'spec/integration/feed_importing_spec.rb' + - 'spec/models/feed_spec.rb' + - 'spec/models/migration_status_spec.rb' + - 'spec/models/story_spec.rb' + - 'spec/repositories/group_repository_spec.rb' + - 'spec/repositories/story_repository_spec.rb' + - 'spec/system/good_job_spec.rb' + - 'spec/tasks/remove_old_stories_spec.rb' + - 'spec/utils/feed_discovery_spec.rb' + - 'spec/utils/opml_parser_spec.rb' + +# Offense count: 16 +RSpec/LeakyLocalVariable: + Exclude: + - 'spec/commands/feed/import_from_opml_spec.rb' + - 'spec/integration/feed_importing_spec.rb' + - 'spec/requests/feeds_controller_spec.rb' + - 'spec/requests/imports_controller_spec.rb' + - 'spec/requests/stories_controller_spec.rb' + - 'spec/utils/feed_discovery_spec.rb' + +# Offense count: 17 +# Configuration parameters: EnforcedStyle. +# SupportedStyles: allow, expect +RSpec/MessageExpectation: + Exclude: + - 'spec/commands/feed/fetch_one_spec.rb' + - 'spec/models/migration_status_spec.rb' + - 'spec/repositories/story_repository_spec.rb' + - 'spec/tasks/remove_old_stories_spec.rb' + - 'spec/utils/i18n_support_spec.rb' + +# Offense count: 26 +# Configuration parameters: Max. +RSpec/MultipleExpectations: + Exclude: + - 'spec/commands/feed/create_spec.rb' + - 'spec/commands/feed/export_to_opml_spec.rb' + - 'spec/commands/feed/import_from_opml_spec.rb' + - 'spec/repositories/feed_repository_spec.rb' + - 'spec/repositories/story_repository_spec.rb' + - 'spec/requests/feeds_controller_spec.rb' + - 'spec/tasks/remove_old_stories_spec.rb' + - 'spec/utils/feed_discovery_spec.rb' + - 'spec/utils/i18n_support_spec.rb' + - 'spec/utils/opml_parser_spec.rb' + +# Offense count: 5 +# Configuration parameters: EnforcedStyle, IgnoreSharedExamples. +# SupportedStyles: always, named_only +RSpec/NamedSubject: + Exclude: + - 'spec/commands/fever_api/write_mark_item_spec.rb' + +# Offense count: 2 +# Configuration parameters: Max, AllowedGroups. +RSpec/NestedGroups: + Exclude: + - 'spec/integration/feed_importing_spec.rb' + +# Offense count: 5 +# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. +RSpec/VerifiedDoubles: + Exclude: + - 'spec/commands/feed/fetch_one_spec.rb' + - 'spec/commands/feed/find_new_stories_spec.rb' + - 'spec/tasks/remove_old_stories_spec.rb' + +# Offense count: 2 +Rails/Env: + Exclude: + - 'config/routes.rb' + - 'spec/rails_helper.rb' + +# Offense count: 2 +# Configuration parameters: IgnoreScopes. +Rails/InverseOf: + Exclude: + - 'app/models/feed.rb' + +# Offense count: 3 +Rails/ReversibleMigrationMethodDefinition: + Exclude: + - 'db/migrate/20130423001740_drop_email_from_user.rb' + - 'db/migrate/20130423180446_remove_author_from_stories.rb' + - 'db/migrate/20130425222157_add_delayed_job.rb' + +# Offense count: 9 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowImplicitReturn, AllowedReceivers. +Rails/SaveBang: + Exclude: + - 'app/commands/feed/create.rb' + - 'app/commands/feed/import_from_opml.rb' + - 'app/repositories/feed_repository.rb' + - 'app/repositories/story_repository.rb' + - 'app/repositories/user_repository.rb' + - 'db/migrate/20130821020313_update_nil_entry_ids.rb' + +# Offense count: 2 +# Configuration parameters: ForbiddenMethods, AllowedMethods. +# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all +Rails/SkipsModelValidations: + Exclude: + - 'db/migrate/20140421224454_fix_invalid_unicode.rb' + - 'db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb' + +# Offense count: 5 +Rails/ThreeStateBooleanColumn: + Exclude: + - 'db/migrate/20130412185253_add_new_fields_to_stories.rb' + - 'db/migrate/20130425211008_add_setup_complete_to_user.rb' + - 'db/migrate/20130513025939_add_keep_unread_to_stories.rb' + - 'db/migrate/20130513044029_add_is_starred_status_for_stories.rb' + - 'db/migrate/20230801025233_create_good_job_executions.rb' + +# Offense count: 6 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: strict, flexible +Rails/TimeZone: + Exclude: + - 'app/commands/feed/find_new_stories.rb' + - 'app/repositories/story_repository.rb' + - 'app/tasks/remove_old_stories.rb' + - 'app/utils/sample_story.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Rails/Validation: + Exclude: + - 'app/models/story.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/IfUnlessModifier: + Exclude: + - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: return, return_nil +Style/ReturnNil: + Exclude: + - 'app/repositories/user_repository.rb' + +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/StaticClass: + Exclude: + - 'app/repositories/feed_repository.rb' + - 'app/repositories/group_repository.rb' + - 'app/repositories/story_repository.rb' + - 'app/repositories/user_repository.rb' + +# Offense count: 1 +Style/TopLevelMethodDefinition: + Exclude: + - 'spec/integration/feed_importing_spec.rb' diff --git a/.ruby-version b/.ruby-version index 0bee604df..fcdb2e109 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.3 +4.0.0 diff --git a/.stringer.env b/.stringer.env new file mode 100644 index 000000000..12499378e --- /dev/null +++ b/.stringer.env @@ -0,0 +1,8 @@ +SECRET_KEY_BASE=5e1a0474b6c8b517c58a676bb9baae9da8fc82d4c5a13a42a1b69c3b310fe666ff824bfe9426db1c0814ea83fdacb1a8f80eed90ae3501006ea17440136620d9 +ENCRYPTION_PRIMARY_KEY=773dddc695536c2e7bcfe7e56f1bfc9ba29a793663304a6b6ef4764867fd7fdb93b9bbf52f3cf67b11b5bcb31d1da3583202e216e08d645363d453feef776a60 +ENCRYPTION_DETERMINISTIC_KEY=a827a6e936dec463b1635803bb19b96815b74e7aa871c656ac8bce45c070dbdf893300ff5e633ee5143efcf5915ee52c760d851e1bfb48f794ac12a40d433398 +ENCRYPTION_KEY_DERIVATION_SALT=63a7e1e618d35721a98d9d71628bda6d5e4b154f5357ad84d78b20b3be09416a204dce57ba1bf1946cfcad05cc990ffc6e8693d801ee4b184d6ba92d5831d68a + +DATABASE_URL=postgres://:@/ +FETCH_FEEDS_CRON='*/5 * * * *' +CLEANUP_CRON='0 0 * * *' diff --git a/.tool-versions b/.tool-versions index 69ed28bbf..20d0a87d4 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,3 @@ -ruby 2.3.3 +ruby 4.0.0 +bundler 2.6.2 +postgres 14.6 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 501560eae..000000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -before_install: - - gem update bundler - - 'echo "RUBY_VERSION: $RUBY_VERSION"' -before_script: - - npm install -g mocha-phantomjs@4.1.0 - - bundle exec rake test_js &> /dev/null & - - sleep 5 -cache: bundler -language: ruby -rvm: - - 2.3.3 - - ruby-head -matrix: - allow_failures: - - rvm: ruby-head -script: - - bundle exec rspec - - mocha-phantomjs http://localhost:4567/test - - bundle exec rubocop -sudo: false diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..fa581a793 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +robert@boon.gl. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile index 8ccb1ad59..7a7f4a113 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,21 @@ -FROM ruby:2.3.3 +FROM ruby:4.0.0 ENV RACK_ENV=production +ENV RAILS_ENV=production ENV PORT=8080 +ENV BUNDLER_VERSION=2.6.2 EXPOSE 8080 +SHELL ["/bin/bash", "-c"] + WORKDIR /app ADD Gemfile Gemfile.lock /app/ -RUN bundle install --deployment +RUN gem install bundler:$BUNDLER_VERSION && bundle install RUN apt-get update \ && apt-get install -y --no-install-recommends \ - supervisor locales nodejs \ + supervisor locales nodejs vim nano \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -19,14 +23,18 @@ RUN DEBIAN_FRONTEND=noninteractive dpkg-reconfigure locales \ && locale-gen C.UTF-8 \ && /usr/sbin/update-locale LANG=C.UTF-8 -ENV LC_ALL C.UTF-8 +ENV LC_ALL=C.UTF-8 -ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.1.3/supercronic-linux-amd64 \ - SUPERCRONIC=supercronic-linux-amd64 \ - SUPERCRONIC_SHA1SUM=96960ba3207756bb01e6892c978264e5362e117e +ARG TARGETARCH +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.1.3/supercronic-linux-$TARGETARCH \ + SUPERCRONIC=supercronic-linux-$TARGETARCH \ + SUPERCRONIC_amd64_SHA1SUM=96960ba3207756bb01e6892c978264e5362e117e \ + SUPERCRONIC_arm_SHA1SUM=8c1e7af256ee35a9fcaf19c6a22aa59a8ccc03ef \ + SUPERCRONIC_arm64_SHA1SUM=f0e8049f3aa8e24ec43e76955a81b76e90c02270 \ + SUPERCRONIC_SHA1SUM="SUPERCRONIC_${TARGETARCH}_SHA1SUM" RUN curl -fsSLO "$SUPERCRONIC_URL" \ - && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && echo "${!SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ && chmod +x "$SUPERCRONIC" \ && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic @@ -39,4 +47,6 @@ RUN useradd -m stringer RUN chown -R stringer:stringer /app USER stringer +ENV RAILS_SERVE_STATIC_FILES=true + CMD /app/start.sh diff --git a/Gemfile b/Gemfile index db9447538..fb40687b5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,49 +1,58 @@ -ruby_version_file = File.join(File.expand_path("..", __FILE__), ".ruby-version") +# frozen_string_literal: true + +ruby_version_file = File.expand_path(".ruby-version", __dir__) ruby File.read(ruby_version_file).chomp if File.readable?(ruby_version_file) source "https://rubygems.org" +git_source(:github) { |repo| "https://github.com/#{repo}.git" } -group :production do - gem "pg", "~> 0.18.4" - gem "unicorn", "~> 4.7" -end +gem "dotenv-rails" + +gem "rails", "~> 8.1.0" + +gem "bcrypt" +gem "bootsnap", require: false +gem "feedbag" +gem "feedjira" +gem "good_job", "~> 4.13.0" +gem "httparty" +gem "nokogiri", "~> 1.19.0" +gem "pg" +gem "puma", "~> 7.0" +gem "rack-ssl" +gem "sass" +gem "sprockets" +gem "sprockets-rails" +gem "stripe" +gem "thread" +gem "uglifier" +gem "will_paginate" group :development do - gem "sqlite3", "~> 1.3", ">= 1.3.8" + gem "rubocop", require: false + gem "rubocop-capybara", require: false + gem "rubocop-factory_bot", require: false + gem "rubocop-rails", require: false + gem "rubocop-rake", require: false + gem "rubocop-rspec", require: false + gem "rubocop-rspec_rails", require: false + gem "web-console" end group :development, :test do - gem "capybara", "~> 2.6" - gem "coveralls", "~> 0.7", require: false - gem "faker", "~> 1.2" - gem "pry-byebug", "~> 1.2" - gem "rack-test", "~> 0.6" - gem "rspec", "~> 3.4" - gem "rspec-html-matchers", "~> 0.7" - gem "rubocop", "~> 0.38", require: false - gem "shotgun", "~> 0.9" - gem "timecop", "~> 0.8" + gem "capybara" + gem "coveralls_reborn", require: false + gem "debug" + gem "factory_bot" + gem "pry-byebug" + gem "rspec" + gem "rspec-rails" + gem "simplecov" + gem "webmock", require: false end -gem "activerecord", "~> 4.2.6" -gem "bcrypt", "~> 3.1" -gem "delayed_job", "~> 4.1" -gem "delayed_job_active_record", "~> 4.1" -gem "feedbag", "~> 0.9.5" -gem "feedjira", "~> 2.1.0" -gem "i18n" -gem "loofah", "~> 2.0" -gem "nokogiri", "~> 1.6", ">= 1.6.7.2" -gem "rack-protection", "~> 1.5" -gem "rack-ssl", "~> 1.4" -gem "racksh", "~> 1.0" -gem "rake", "~> 10.1", ">= 10.1.1" -gem "sass" -gem "sinatra", "~> 1.4.8", ">= 1.4.8" -gem "sinatra-activerecord", "~> 1.2", ">= 1.2.3" -gem "sinatra-contrib", "~> 1.4.7" -gem "sinatra-flash", "~> 0.3" -gem "sprockets", "~> 3.0" -gem "sprockets-helpers" -gem "thread", "~> 0.2" -gem "uglifier" -gem "will_paginate", "~> 3.1" +group :test do + gem "axe-core-rspec" + gem "selenium-webdriver" + gem "webdrivers" + gem "with_model" +end diff --git a/Gemfile.lock b/Gemfile.lock index fe40a9405..66d324c32 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,224 +1,482 @@ GEM remote: https://rubygems.org/ specs: - activemodel (4.2.6) - activesupport (= 4.2.6) + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.2) + activesupport (= 8.1.2) builder (~> 3.1) - activerecord (4.2.6) - activemodel (= 4.2.6) - activesupport (= 4.2.6) - arel (~> 6.0) - activesupport (4.2.6) - i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - addressable (2.4.0) - arel (6.0.3) - ast (2.2.0) - backports (3.6.8) - bcrypt (3.1.11) - builder (3.2.2) - byebug (2.7.0) - columnize (~> 0.3) - debugger-linecache (~> 1.2) - capybara (2.6.2) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.3.6) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) + timeout (>= 0.4.0) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) + marcel (~> 1.0) + activesupport (8.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + axe-core-api (4.11.0) + dumb_delegator + ostruct + virtus + axe-core-rspec (4.11.0) + axe-core-api (= 4.11.0) + dumb_delegator + ostruct + virtus + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) + base64 (0.3.0) + bcrypt (3.1.21) + bigdecimal (4.0.1) + bindex (0.8.1) + bootsnap (1.20.1) + msgpack (~> 1.2) + builder (3.3.0) + byebug (12.0.0) + capybara (3.40.0) addressable - mime-types (>= 1.16) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (~> 2.0) - coderay (1.1.1) - columnize (0.9.0) - concurrent-ruby (1.0.2) - coveralls (0.8.13) - json (~> 1.8) - simplecov (~> 0.11.0) - term-ansicolor (~> 1.3) - thor (~> 0.19.1) - tins (~> 1.6.0) - debugger-linecache (1.2.0) - delayed_job (4.1.1) - activesupport (>= 3.0, < 5.0) - delayed_job_active_record (4.1.0) - activerecord (>= 3.0, < 5) - delayed_job (>= 3.0, < 5) - diff-lcs (1.2.5) - docile (1.1.5) - execjs (2.7.0) - faker (1.6.3) - i18n (~> 0.5) - faraday (0.11.0) - multipart-post (>= 1.2, < 3) - faraday_middleware (0.11.0.1) - faraday (>= 0.7.4, < 1.0) - feedbag (0.9.5) - nokogiri (~> 1.0) - feedjira (2.1.0) - faraday (>= 0.9) - faraday_middleware (>= 0.9) - loofah (>= 2.0) - sax-machine (>= 1.0) - i18n (0.7.0) - json (1.8.3) - kgio (2.10.0) - loofah (2.0.3) - nokogiri (>= 1.5.9) - method_source (0.8.2) - mime-types (3.0) - mime-types-data (~> 3.2015) - mime-types-data (3.2016.0221) - mini_portile2 (2.0.0) - minitest (5.8.4) - multi_json (1.12.1) - multipart-post (2.0.0) - nokogiri (1.6.7.2) - mini_portile2 (~> 2.0.0.rc2) - parser (2.3.0.6) - ast (~> 2.2) - pg (0.18.4) - powerpack (0.1.1) - pry (0.10.3) - coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - pry-byebug (1.3.3) - byebug (~> 2.7) - pry (~> 0.10) - rack (1.6.5) - rack-protection (1.5.3) - rack + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + coderay (1.1.3) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + coveralls_reborn (0.29.0) + simplecov (~> 0.22.0) + term-ansicolor (~> 1.7) + thor (~> 1.2) + tins (~> 1.32) + crack (1.0.1) + bigdecimal + rexml + crass (1.0.6) + csv (3.3.5) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + diff-lcs (1.6.2) + docile (1.4.1) + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) + drb (2.2.3) + dumb_delegator (1.1.0) + erb (6.0.1) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + execjs (2.10.0) + factory_bot (6.5.6) + activesupport (>= 6.1.0) + feedbag (1.0.2) + addressable (~> 2.8) + nokogiri (~> 1.8, >= 1.8.2) + feedjira (4.0.1) + logger (>= 1.0, < 2) + loofah (>= 2.3.1, < 3) + sax-machine (>= 1.0, < 2) + ffi (1.17.3) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + good_job (4.13.1) + activejob (>= 6.1.0) + activerecord (>= 6.1.0) + concurrent-ruby (>= 1.3.1) + fugit (>= 1.11.0) + railties (>= 6.1.0) + thor (>= 1.0.0) + hashdiff (1.2.1) + httparty (0.24.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.18.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + method_source (1.1.0) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (6.0.1) + prism (~> 1.5) + mize (0.6.1) + msgpack (1.8.0) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) + net-imap (0.6.2) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.0) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.7.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.11.0) + byebug (~> 12.0) + pry (>= 0.13, < 0.16) + psych (5.3.1) + date + stringio + public_suffix (7.0.2) + puma (7.1.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) rack-ssl (1.4.1) rack - rack-test (0.6.3) - rack (>= 1.0) - racksh (1.0.0) - rack (>= 1.0) - rack-test (>= 0.5) - rainbow (2.1.0) - raindrops (0.16.0) - rake (10.5.0) - rspec (3.4.0) - rspec-core (~> 3.4.0) - rspec-expectations (~> 3.4.0) - rspec-mocks (~> 3.4.0) - rspec-core (3.4.4) - rspec-support (~> 3.4.0) - rspec-expectations (3.4.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + bundler (>= 1.15.0) + railties (= 8.1.2) + 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 (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + 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.3.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rdoc (7.0.3) + erb + psych (>= 4.0.0) + tsort + readline (0.0.4) + reline + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.4.0) - rspec-html-matchers (0.7.1) - nokogiri (~> 1) - rspec (>= 3.0.0.a, < 4) - rspec-mocks (3.4.1) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.4.0) - rspec-support (3.4.1) - rubocop (0.38.0) - parser (>= 2.3.0.6, < 3.0) - powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) + rubocop (1.82.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.7.5) - sass (3.4.22) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + rubocop-rspec_rails (2.32.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + ruby-progressbar (1.13.0) + rubyzip (2.4.1) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) sax-machine (1.3.2) - shotgun (0.9.1) - rack (>= 1.0) - simplecov (0.11.2) - docile (~> 1.1.0) - json (~> 1.8) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.0) - sinatra (1.4.8) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - sinatra-activerecord (1.7.0) - activerecord (>= 3.0) - sinatra (~> 1.0) - sinatra-contrib (1.4.7) - backports (>= 2.0) - multi_json - rack-protection - rack-test - sinatra (~> 1.4.0) - tilt (>= 1.3, < 3) - sinatra-flash (0.3.0) - sinatra (>= 1.0.0) - slop (3.6.0) - sprockets (3.7.0) + securerandom (0.4.1) + selenium-webdriver (4.10.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sprockets (4.2.2) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-helpers (1.2.1) - sprockets (>= 2.2) - sqlite3 (1.3.11) - term-ansicolor (1.3.2) - tins (~> 1.0) - thor (0.19.1) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + stringio (3.2.0) + stripe (18.1.0) + sync (0.5.0) + term-ansicolor (1.11.3) + tins (~> 1) + thor (1.5.0) thread (0.2.2) - thread_safe (0.3.5) - tilt (1.4.1) - timecop (0.8.0) - tins (1.6.0) - tzinfo (1.2.2) - thread_safe (~> 0.1) - uglifier (3.0.2) + thread_safe (0.3.6) + timeout (0.6.0) + tins (1.51.0) + bigdecimal + mize (~> 0.6) + readline + sync + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uglifier (4.2.1) execjs (>= 0.3.0, < 3) - unicode-display_width (1.0.2) - unicorn (4.9.0) - kgio (~> 2.6) - rack - raindrops (~> 0.7) - will_paginate (3.1.0) - xpath (2.0.0) - nokogiri (~> 1.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + virtus (2.0.0) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webdrivers (5.3.1) + nokogiri (~> 1.6) + rubyzip (>= 1.3.0) + selenium-webdriver (~> 4.0, < 4.11) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + will_paginate (4.0.1) + with_model (2.2.0) + activerecord (>= 7.0) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.4) PLATFORMS ruby DEPENDENCIES - activerecord (~> 4.2.6) - bcrypt (~> 3.1) - capybara (~> 2.6) - coveralls (~> 0.7) - delayed_job (~> 4.1) - delayed_job_active_record (~> 4.1) - faker (~> 1.2) - feedbag (~> 0.9.5) - feedjira (~> 2.1.0) - i18n - loofah (~> 2.0) - nokogiri (~> 1.6, >= 1.6.7.2) - pg (~> 0.18.4) - pry-byebug (~> 1.2) - rack-protection (~> 1.5) - rack-ssl (~> 1.4) - rack-test (~> 0.6) - racksh (~> 1.0) - rake (~> 10.1, >= 10.1.1) - rspec (~> 3.4) - rspec-html-matchers (~> 0.7) - rubocop (~> 0.38) + axe-core-rspec + bcrypt + bootsnap + capybara + coveralls_reborn + debug + dotenv-rails + factory_bot + feedbag + feedjira + good_job (~> 4.13.0) + httparty + nokogiri (~> 1.19.0) + pg + pry-byebug + puma (~> 7.0) + rack-ssl + rails (~> 8.1.0) + rspec + rspec-rails + rubocop + rubocop-capybara + rubocop-factory_bot + rubocop-rails + rubocop-rake + rubocop-rspec + rubocop-rspec_rails sass - shotgun (~> 0.9) - sinatra (~> 1.4.8, >= 1.4.8) - sinatra-activerecord (~> 1.2, >= 1.2.3) - sinatra-contrib (~> 1.4.7) - sinatra-flash (~> 0.3) - sprockets (~> 3.0) - sprockets-helpers - sqlite3 (~> 1.3, >= 1.3.8) - thread (~> 0.2) - timecop (~> 0.8) + selenium-webdriver + simplecov + sprockets + sprockets-rails + stripe + thread uglifier - unicorn (~> 4.7) - will_paginate (~> 3.1) + web-console + webdrivers + webmock + will_paginate + with_model RUBY VERSION - ruby 2.3.3p222 + ruby 4.0.0 BUNDLED WITH - 1.14.6 + 2.6.2 diff --git a/Procfile b/Procfile index dfa2ae6e9..819eb20a1 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1 @@ -web: bundle exec unicorn -p $PORT -c ./config/unicorn.rb -console: bundle exec racksh +web: bundle exec puma -C ./config/puma.rb diff --git a/README.md b/README.md index f7e0806c2..37a04b641 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Stringer -[![Build Status](https://api.travis-ci.org/swanson/stringer.svg?style=flat)](https://travis-ci.org/swanson/stringer) -[![Code Climate](https://codeclimate.com/github/swanson/stringer.svg?style=flat)](https://codeclimate.com/github/swanson/stringer) -[![Coverage Status](https://coveralls.io/repos/swanson/stringer/badge.svg?style=flat)](https://coveralls.io/r/swanson/stringer) -[![Dependency Status](https://gemnasium.com/swanson/stringer.svg)](https://gemnasium.com/swanson/stringer) +[![CircleCI](https://circleci.com/gh/stringer-rss/stringer/tree/main.svg?style=svg)](https://circleci.com/gh/stringer-rss/stringer/tree/main) +[![Code Climate](https://api.codeclimate.com/v1/badges/899c5407c870e541af4e/maintainability)](https://codeclimate.com/github/stringer-rss/stringer/maintainability) +[![Coverage Status](https://coveralls.io/repos/github/stringer-rss/stringer/badge.svg?branch=main)](https://coveralls.io/github/stringer-rss/stringer?branch=main) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/mockdeep?logo=github)](https://github.com/sponsors/mockdeep) ### A self-hosted, anti-social RSS reader. @@ -17,14 +17,14 @@ But it does have keyboard shortcuts and was made with love! ## Installation -Stringer is a Ruby (2.3.0+) app based on Sinatra, ActiveRecord, PostgreSQL, Backbone.js and DelayedJob. +Stringer is a Ruby app based on Rails, PostgreSQL, Backbone.js and GoodJob. -[![Deploy to Heroku](https://cdn.herokuapp.com/deploy/button.svg)](https://heroku.com/deploy) +[![Deploy to Heroku](https://cdn.herokuapp.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/stringer-rss/stringer) -Stringer will run just fine on the Heroku free plan. +Stringer will run just fine on the Eco/Basic Heroku plans. Instructions are provided for deploying to [Heroku manually](/docs/Heroku.md), to any Ruby -compatible [Linux-based VPS](/docs/VPS.md), to [Docker](docs/docker.md) and to [OpenShift](/docs/OpenShift.md). +compatible [Linux-based VPS](/docs/VPS.md), to [Docker](docs/Docker.md) and to [OpenShift](/docs/OpenShift.md). ## Niceties @@ -34,7 +34,7 @@ You can access the keyboard shortcuts when using the app by hitting `?`. ![](screenshots/keyboard_shortcuts.png) -### Using you own domain with Heroku +### Using your own domain with Heroku You can run Stringer at `http://reader.yourdomain.com` using a CNAME. @@ -68,8 +68,6 @@ Email: stringer (case-sensitive) Password: {your-stringer-password} ``` -If you have previously setup Stringer, you will need to migrate your database and run `rake change_password` for the API key to be setup properly. - ### Translations Stringer has been translated to [several other languages](config/locales). Your language can be set with the `LOCALE` environment variable. @@ -80,9 +78,6 @@ If you would like to translate Stringer to your preferred language, please use [ ### Clean up old read stories on Heroku -If you are on the Heroku free plan, there is a 10k row limit so you will -eventually run out of space. - You can clean up old stories by running: `rake cleanup_old_stories` By default, this removes read stories that are more than 30 days old (that @@ -105,15 +100,15 @@ Then run the following commands. ```sh bundle install -rake db:migrate +rails db:setup foreman start ``` The application will be running on port `5000`. -You can launch an interactive console (a la `rails c`) using `racksh`. +You can launch an interactive console (a la `rails c`) using `rake console`. -## Acknowledgements +## Acknowledgments Most of the heavy-lifting is done by [`feedjira`](https://github.com/feedjira/feedjira) and [`feedbag`](https://github.com/dwillis/feedbag). @@ -125,10 +120,13 @@ Lato Font Copyright © 2010-2011 by tyPoland Lukasz Dziedzic (team@latofonts ## Contact -If you have a question, feature idea, or are running into problems, our preferred method of contact is to open an issue on GitHub. This allows multiple people to weigh in and we can keep everything in one place. Thanks! +If you have a question, feature idea, or are running into problems, our preferred method of contact is to open an issue on GitHub. This allows multiple people to weigh in, and we can keep everything in one place. Thanks! ## Maintainers -Matt Swanson, [mdswanson.com](http://mdswanson.com), [@_swanson](http://twitter.com/_swanson) +Robert Fletcher [boon.gl](https://boon.gl) + +## Alumni +Matt Swanson (creator), [mdswanson.com](http://mdswanson.com), [@_swanson](http://twitter.com/_swanson) Victor Koronen, [victor.koronen.se](http://victor.koronen.se/), [@victorkoronen](https://twitter.com/victorkoronen) diff --git a/Rakefile b/Rakefile index 65669e57e..5fc8d463b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,88 +1,35 @@ -require "bundler" -Bundler.setup +# frozen_string_literal: true -require "rubygems" -require "net/http" -require "active_record" -require "delayed_job" -require "delayed_job_active_record" +require_relative "config/application" -require "sinatra/activerecord/rake" -ActiveRecord::Tasks::DatabaseTasks.db_dir = "db" - -require "./app" -require_relative "./app/jobs/fetch_feed_job" -require_relative "./app/tasks/fetch_feeds" -require_relative "./app/tasks/change_password" -require_relative "./app/tasks/remove_old_stories.rb" +Rails.application.load_tasks desc "Fetch all feeds." -task :fetch_feeds do - FetchFeeds.new(Feed.all).fetch_all +task fetch_feeds: :environment do + Feed::FetchAll.call end desc "Lazily fetch all feeds." -task :lazy_fetch do +task lazy_fetch: :environment do if ENV["APP_URL"] uri = URI(ENV["APP_URL"]) + + # warm up server by fetching the root path Net::HTTP.get_response(uri) end FeedRepository.list.each do |feed| - Delayed::Job.enqueue FetchFeedJob.new(feed.id) + CallableJob.perform_later(Feed::FetchOne, feed) end end desc "Fetch single feed" -task :fetch_feed, :id do |_t, args| - FetchFeed.new(Feed.find(args[:id])).fetch -end - -desc "Clear the delayed_job queue." -task :clear_jobs do - Delayed::Job.delete_all -end - -desc "Work the delayed_job queue." -task :work_jobs do - Delayed::Job.delete_all - - worker_retry = Integer(ENV["WORKER_RETRY"] || 3) - worker_retry.times do - Delayed::Worker.new( - min_priority: ENV["MIN_PRIORITY"], - max_priority: ENV["MAX_PRIORITY"] - ).start - end -end - -desc "Change your password" -task :change_password do - ChangePassword.new.change_password +task :fetch_feed, [:id] => :environment do |_t, args| + Feed::FetchOne.call(Feed.find(args[:id])) end desc "Clean up old stories that are read and unstarred" -task :cleanup_old_stories, :number_of_days do |_t, args| +task :cleanup_old_stories, [:number_of_days] => :environment do |_t, args| args.with_defaults(number_of_days: 30) - RemoveOldStories.remove!(args[:number_of_days].to_i) -end - -desc "Start server and serve JavaScript test suite at /test" -task :test_js do - require_relative "./spec/javascript/test_controller" - Stringer.run! -end - -begin - require "rspec/core/rake_task" - - RSpec::Core::RakeTask.new(:speedy_tests) do |t| - t.rspec_opts = "--tag ~speed:slow" - end - - RSpec::Core::RakeTask.new(:spec) - - task default: [:speedy_tests] -rescue LoadError # rubocop:disable Lint/HandleExceptions - # allow for bundle install --without development:test + RemoveOldStories.call(args[:number_of_days].to_i) end diff --git a/app.json b/app.json index 33f03cdaf..a82ae22ba 100644 --- a/app.json +++ b/app.json @@ -1,19 +1,31 @@ { "name": "Stringer", "description": "A self-hosted, anti-social RSS reader.", - "logo": "https://raw.githubusercontent.com/swanson/stringer/master/screenshots/logo.png", + "logo": "https://raw.githubusercontent.com/stringer-rss/stringer/main/screenshots/logo.png", "keywords": [ "RSS", "Ruby" ], - "website": "https://github.com/swanson/stringer", + "website": "https://github.com/stringer-rss/stringer", "success_url": "/heroku", "scripts": { "postdeploy": "bundle exec rake db:migrate" }, "env": { - "SECRET_TOKEN": { - "description": "Secret key used as the session secret", + "SECRET_KEY_BASE": { + "description": "Secret key used by rails for encryption", + "generator": "secret" + }, + "ENCRYPTION_PRIMARY_KEY": { + "description": "Secret key used by rails for encryption", + "generator": "secret" + }, + "ENCRYPTION_DETERMINISTIC_KEY": { + "description": "Secret key used by rails for encryption", + "generator": "secret" + }, + "ENCRYPTION_KEY_DERIVATION_SALT": { + "description": "Secret key used by rails for encryption", "generator": "secret" }, "LOCALE": { diff --git a/app.rb b/app.rb deleted file mode 100644 index 1169a3b32..000000000 --- a/app.rb +++ /dev/null @@ -1,86 +0,0 @@ -require "sinatra/base" -require "sinatra/activerecord" -require "sinatra/flash" -require "sinatra/contrib/all" -require "rack/ssl" -require "json" -require "i18n" -require "will_paginate" -require "will_paginate/active_record" -require "sprockets" -require "sprockets-helpers" -require "securerandom" - -require_relative "app/helpers/authentication_helpers" -require_relative "app/repositories/user_repository" -require_relative "config/asset_pipeline" - -I18n.load_path += Dir[File.join(File.dirname(__FILE__), "config/locales", "*.yml").to_s] -I18n.config.enforce_available_locales = false - -class Stringer < Sinatra::Base - # need to exclude assets for sinatra assetpack, see https://github.com/swanson/stringer/issues/112 - use Rack::SSL, exclude: ->(env) { env["PATH_INFO"] =~ %r{^/(js|css|img)} } if ENV["ENFORCE_SSL"] == "true" - - register Sinatra::ActiveRecordExtension - register Sinatra::Flash - register Sinatra::Contrib - register AssetPipeline - - configure do - set :database_file, "config/database.yml" - set :views, "app/views" - set :public_dir, "app/public" - set :root, File.dirname(__FILE__) - - enable :sessions - set :session_secret, ENV["SECRET_TOKEN"] || SecureRandom.hex(32) - enable :logging - enable :method_override - - ActiveRecord::Base.include_root_in_json = false - end - - helpers do - include Sinatra::AuthenticationHelpers - - def render_partial(name, locals = {}) - erb "partials/_#{name}".to_sym, layout: false, locals: locals - end - - def render_js_template(name) - erb "js/templates/_#{name}.js".to_sym, layout: false - end - - def render_js(name, locals = {}) - erb "js/#{name}.js".to_sym, layout: false, locals: locals - end - - def t(*args) - I18n.t(*args) - end - end - - before do - I18n.locale = ENV["LOCALE"].blank? ? :en : ENV["LOCALE"].to_sym - - if !authenticated? && needs_authentication?(request.path) - session[:redirect_to] = request.fullpath - redirect "/login" - end - end - - get "/" do - if UserRepository.setup_complete? - redirect to("/news") - else - redirect to("/setup/password") - end - end -end - -require_relative "app/controllers/stories_controller" -require_relative "app/controllers/first_run_controller" -require_relative "app/controllers/sessions_controller" -require_relative "app/controllers/feeds_controller" -require_relative "app/controllers/debug_controller" diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 000000000..7d52bcf35 --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 7d2c83dfb..720a7efe7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -11,6 +11,16 @@ _.templateSettings = { evaluate: /\{\{(.+?)\}\}/g }; +function CSRFToken() { + const tokenTag = document.getElementsByName('csrf-token')[0]; + + return (tokenTag && tokenTag.content) || ''; +} + +function requestHeaders() { + return { 'X-CSRF-Token': CSRFToken() }; +} + var Story = Backbone.Model.extend({ defaults: function() { return { @@ -33,7 +43,7 @@ var Story = Backbone.Model.extend({ open: function() { if (!this.get("keep_unread")) this.set("is_read", true); - if (this.shouldSave()) this.save(); + if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); if(this.collection){ this.collection.closeOthers(this); @@ -54,7 +64,7 @@ var Story = Backbone.Model.extend({ this.set("is_read", false); } - if (this.shouldSave()) this.save(); + if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); }, toggleStarred: function() { @@ -64,7 +74,7 @@ var Story = Backbone.Model.extend({ this.set("is_starred", true); } - if (this.shouldSave()) this.save(); + if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); }, close: function() { @@ -156,7 +166,7 @@ var StoryView = Backbone.View.extend({ if (backgroundTab) backgroundTab.blur(); window.focus(); if (!this.model.get("keep_unread")) this.model.set("is_read", true); - if (this.model.shouldSave()) this.model.save(); + if (this.model.shouldSave()) this.model.save(null, { headers: requestHeaders() }); } else { this.model.toggle(); window.scrollTo(0, this.$el.offset().top); @@ -324,23 +334,6 @@ var AppView = Backbone.View.extend({ }); $(document).ready(function() { - $(".remove-feed").click(function(e) { - e.preventDefault(); - var $this = $(this); - - var feedId = $this.parents("li").data("id"); - - if (feedId > 0) { - $.ajax({url: "/feeds/" + feedId, type: "DELETE"}) - .success(function() { - $this.parents("li").fadeOut(500, function () { - $(this).remove(); - }); - }) - .fail(function() { alert("something broke!"); }); - } - }); - Mousetrap.bind("?", function() { $("#shortcuts").modal('toggle'); }); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 83dc26c0b..e37f2a5b9 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -23,6 +23,10 @@ code { white-space: normal; } +.warning { + background-color: #F2DEDE; +} + .container { width: 100%; max-width: 720px; @@ -51,6 +55,23 @@ code { border-top: 7px solid #484948; } +@media (max-width: 768px) { + #footer .row-fluid > * { + display: none; + } + + #footer .row-fluid > .span6:first-child { + display: block; + width: 100%; + text-align: center; + } + + #frame { + padding-top: 7px; + border-top: none; + } +} + .alert { text-align: center; -webkit-border-radius: 0px; @@ -65,6 +86,11 @@ code { width: 18px; } +#action-bar button.btn { + height: 34px; + width: 34px; +} + .btn { position: relative; top: 0px; @@ -108,6 +134,13 @@ code { margin-bottom: 14px; } +@media (max-width: 768px) { + #action-bar { + margin-left: 8px; + margin-right: 8px; + } +} + ul#story-list, ul#feed-list { list-style-type: none; margin-left: 0px; @@ -176,14 +209,18 @@ li.story.open .story-preview { } .story-lead { - color: #e5e5e5; + color: #c5c5c5; } .story-published { margin-left: 20px; } -.story-keep-unread, .story-starred { +.story-enclosure { + float: right; +} + +.story-keep-unread, .story-starred { display: inline-block; cursor: pointer; -webkit-touch-callout: none; @@ -251,7 +288,7 @@ li.story.open .story-body-container { } p.story-details { - margin-right: 5px; + margin-right: 14px; overflow: hidden; } @@ -319,8 +356,16 @@ p.story-details { color: #7F8281; } +nav { + display: flex; + justify-content: space-between; + align-items: center; +} + li.feed .feed-line { cursor: default; + display: flex; + justify-content: space-between; } li.feed .feed-title { @@ -369,13 +414,25 @@ li.feed .feed-last-updated { text-align: right; } +@media (max-width: 768px) { + li.feed .feed-last-updated { + display: none; + } + + li.feed .row-fluid .span2 { + float: right; + margin-right: 21px; + } +} + li.feed .last-updated { font-size: 10px; } -li.feed .last-updated-time { - width: 100px; - display: inline-block; +.feed-actions { + display: flex; + gap: 10px; + justify-content: space-between; } li.feed .edit-feed { @@ -383,10 +440,6 @@ li.feed .edit-feed { } li.feed .edit-feed a { - text-align: right; - padding-left: 3px; - padding-right: 3px; - margin-left: 5px; color: #000; } @@ -396,9 +449,6 @@ li.feed .remove-feed { li.feed .remove-feed a { text-align: center; - padding-left: 3px; - padding-right: 3px; - margin-left: 5px; color: #C0392B; } @@ -436,8 +486,12 @@ li.feed .remove-feed a:hover { padding-top: 10px; } +.setup__label { + font-weight: bold; +} + .setup { - width: 350px; + width: 500px; margin: 0 auto; padding-top: 100px; } @@ -493,7 +547,7 @@ li.feed .remove-feed a:hover { transition: 0.25s; } -.setup #password, .setup #password-confirmation, .setup #feed-url, .setup #feed-name, .setup input.select-dummy { +.setup .form-control { padding-left: 100px; padding-right: 36px; } diff --git a/app/commands/cast_boolean.rb b/app/commands/cast_boolean.rb new file mode 100644 index 000000000..7c0c59ae5 --- /dev/null +++ b/app/commands/cast_boolean.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module CastBoolean + TRUE_VALUES = Set.new(["true", true, "1"]).freeze + FALSE_VALUES = Set.new(["false", false, "0"]).freeze + + def self.call(boolean) + if (TRUE_VALUES + FALSE_VALUES).exclude?(boolean) + raise(ArgumentError, "cannot cast to boolean: #{boolean.inspect}") + end + + TRUE_VALUES.include?(boolean) + end +end diff --git a/app/commands/feed/create.rb b/app/commands/feed/create.rb new file mode 100644 index 000000000..ca4cc50dd --- /dev/null +++ b/app/commands/feed/create.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Feed::Create + def self.call(url, user:) + result = FeedDiscovery.call(url) + return false unless result + + name = ContentSanitizer.call(result.title.presence || result.feed_url) + + Feed.create(name:, user:, url: result.feed_url, last_fetched: 1.day.ago) + end +end diff --git a/app/commands/feed/export_to_opml.rb b/app/commands/feed/export_to_opml.rb new file mode 100644 index 000000000..bc913d0e1 --- /dev/null +++ b/app/commands/feed/export_to_opml.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Feed::ExportToOpml + class << self + def call(feeds) + builder = + Nokogiri::XML::Builder.new do |xml| + xml.opml(version: "1.0") do + xml.head { xml.title("Feeds from Stringer") } + xml.body { feeds.each { |feed| feed_outline(xml, feed) } } + end + end + + builder.to_xml + end + + private + + def feed_outline(xml, feed) + xml.outline( + text: feed.name, + title: feed.name, + type: "rss", + xmlUrl: feed.url + ) + end + end +end diff --git a/app/commands/feed/fetch_all.rb b/app/commands/feed/fetch_all.rb new file mode 100644 index 000000000..c1f140cef --- /dev/null +++ b/app/commands/feed/fetch_all.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "thread/pool" + +module Feed::FetchAll + def self.call + pool = Thread.pool(10) + + Feed.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } } + + pool.shutdown + end +end diff --git a/app/commands/feed/fetch_all_for_user.rb b/app/commands/feed/fetch_all_for_user.rb new file mode 100644 index 000000000..65bd42427 --- /dev/null +++ b/app/commands/feed/fetch_all_for_user.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "thread/pool" + +module Feed::FetchAllForUser + def self.call(user) + pool = Thread.pool(10) + + user.feeds.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } } + + pool.shutdown + end +end diff --git a/app/commands/feed/fetch_one.rb b/app/commands/feed/fetch_one.rb new file mode 100644 index 000000000..6d71b5f49 --- /dev/null +++ b/app/commands/feed/fetch_one.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Feed::FetchOne + class << self + def call(feed) + raw_feed = fetch_raw_feed(feed) + + new_entries_from(feed, raw_feed).each do |entry| + StoryRepository.add(entry, feed) + end + + FeedRepository.update_last_fetched(feed, raw_feed.last_modified) + + FeedRepository.set_status(:green, feed) + rescue StandardError => e + FeedRepository.set_status(:red, feed) + + Rails.logger.error("Something went wrong when parsing #{feed.url}: #{e}") + end + + private + + def fetch_raw_feed(feed) + response = HTTParty.get(feed.url).to_s + Feedjira.parse(response) + end + + def new_entries_from(feed, raw_feed) + Feed::FindNewStories.call(raw_feed, feed.id, latest_entry_id(feed)) + end + + def latest_entry_id(feed) + feed.stories.first.entry_id unless feed.stories.empty? + end + end +end diff --git a/app/commands/feed/find_new_stories.rb b/app/commands/feed/find_new_stories.rb new file mode 100644 index 000000000..e46ea9ca1 --- /dev/null +++ b/app/commands/feed/find_new_stories.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Feed::FindNewStories + STORY_AGE_THRESHOLD_DAYS = 3 + + def self.call(raw_feed, feed_id, latest_entry_id = nil) + stories = [] + + raw_feed.entries.each do |story| + break if latest_entry_id && story.id == latest_entry_id + next if story_age_exceeds_threshold?(story) || StoryRepository.exists?( + story.id, + feed_id + ) + + stories << story + end + + stories + end + + def self.story_age_exceeds_threshold?(story) + max_age = Time.now - STORY_AGE_THRESHOLD_DAYS.days + story.published && story.published < max_age + end +end diff --git a/app/commands/feed/import_from_opml.rb b/app/commands/feed/import_from_opml.rb new file mode 100644 index 000000000..16d23faf3 --- /dev/null +++ b/app/commands/feed/import_from_opml.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Feed::ImportFromOpml + class << self + def call(opml_contents, user:) + feeds_with_groups = OpmlParser.call(opml_contents) + + # It considers a situation when feeds are already imported without + # groups, so it's possible to re-import the same subscriptions.xml just + # to set group_id for existing feeds. Feeds without groups are in + # 'Ungrouped' group, we don't create such group and create such feeds + # with group_id = nil. + feeds_with_groups.each do |group_name, parsed_feeds| + group = find_or_create_group(group_name, user) + + parsed_feeds.each do |parsed_feed| + create_feed(parsed_feed, group, user) + end + end + end + + private + + def find_or_create_group(group_name, user) + return if group_name == "Ungrouped" + + user.groups.create_or_find_by(name: group_name) + end + + def create_feed(parsed_feed, group, user) + feed = user.feeds.where(**parsed_feed.slice(:name, :url)) + .first_or_initialize + find_feed_name(feed, parsed_feed) + feed.last_fetched = 1.day.ago if feed.new_record? + feed.group_id = group.id if group + feed.save + end + + def find_feed_name(feed, parsed_feed) + return if feed.name? + + result = FeedDiscovery.call(parsed_feed[:url]) + title = result.title if result + feed.name = ContentSanitizer.call(title.presence || parsed_feed[:url]) + end + end +end diff --git a/app/commands/feeds/add_new_feed.rb b/app/commands/feeds/add_new_feed.rb deleted file mode 100644 index 1334e1aed..000000000 --- a/app/commands/feeds/add_new_feed.rb +++ /dev/null @@ -1,15 +0,0 @@ -require_relative "../../models/feed" -require_relative "../../utils/feed_discovery" - -class AddNewFeed - ONE_DAY = 24 * 60 * 60 - - def self.add(url, discoverer = FeedDiscovery.new, repo = Feed) - result = discoverer.discover(url) - return false unless result - - repo.create(name: result.title, - url: result.feed_url, - last_fetched: Time.now - ONE_DAY) - end -end diff --git a/app/commands/feeds/export_to_opml.rb b/app/commands/feeds/export_to_opml.rb deleted file mode 100644 index 707274e60..000000000 --- a/app/commands/feeds/export_to_opml.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "nokogiri" - -class ExportToOpml - def initialize(feeds) - @feeds = feeds - end - - def to_xml # rubocop:disable Metrics/MethodLength - builder = Nokogiri::XML::Builder.new do |xml| - xml.opml(version: "1.0") do - xml.head do - xml.title "Feeds from Stringer" - end - xml.body do - @feeds.each do |feed| - xml.outline( - text: feed.name, - title: feed.name, - type: "rss", - xmlUrl: feed.url - ) - end - end - end - end - - builder.to_xml - end -end diff --git a/app/commands/feeds/find_new_stories.rb b/app/commands/feeds/find_new_stories.rb deleted file mode 100644 index e8bd10678..000000000 --- a/app/commands/feeds/find_new_stories.rb +++ /dev/null @@ -1,31 +0,0 @@ -require_relative "../../repositories/story_repository" - -class FindNewStories - STORY_AGE_THRESHOLD_DAYS = 3 - - def initialize(raw_feed, feed_id, last_fetched, latest_entry_id = nil) - @raw_feed = raw_feed - @feed_id = feed_id - @last_fetched = last_fetched - @latest_entry_id = latest_entry_id - end - - def new_stories - stories = [] - - @raw_feed.entries.each do |story| - break if @latest_entry_id && story.id == @latest_entry_id - next if story_age_exceeds_threshold?(story) || StoryRepository.exists?(story.id, @feed_id) - stories << story - end - - stories - end - - private - - def story_age_exceeds_threshold?(story) - max_age = Time.now - STORY_AGE_THRESHOLD_DAYS.days - story.published && story.published < max_age - end -end diff --git a/app/commands/feeds/import_from_opml.rb b/app/commands/feeds/import_from_opml.rb deleted file mode 100644 index 9fb6ae47b..000000000 --- a/app/commands/feeds/import_from_opml.rb +++ /dev/null @@ -1,34 +0,0 @@ -require_relative "../../models/feed" -require_relative "../../models/group" -require_relative "../../utils/opml_parser" - -class ImportFromOpml - ONE_DAY = 24 * 60 * 60 - - class << self - def import(opml_contents) - feeds_with_groups = OpmlParser.new.parse_feeds(opml_contents) - - # It considers a situation when feeds are already imported without groups, - # so it's possible to re-import the same subscriptions.xml just to set group_id - # for existing feeds. Feeds without groups are in 'Ungrouped' group, we don't - # create such group and create such feeds with group_id = nil. - feeds_with_groups.each do |group_name, parsed_feeds| - next if parsed_feeds.empty? - - group = Group.where(name: group_name).first_or_create unless group_name == "Ungrouped" - - parsed_feeds.each { |parsed_feed| create_feed(parsed_feed, group) } - end - end - - private - - def create_feed(parsed_feed, group) - feed = Feed.where(name: parsed_feed[:name], url: parsed_feed[:url]).first_or_initialize - feed.last_fetched = Time.now - ONE_DAY if feed.new_record? - feed.group_id = group.id if group - feed.save - end - end -end diff --git a/app/commands/fever_api.rb b/app/commands/fever_api.rb new file mode 100644 index 000000000..bcb975d3f --- /dev/null +++ b/app/commands/fever_api.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module FeverAPI + API_VERSION = 3 + + PARAMS = [ + :as, + :before, + :favicons, + :feeds, + :groups, + :id, + :items, + :links, + :mark, + :saved_item_ids, + :since_id, + :unread_item_ids, + :with_ids + ].freeze +end diff --git a/app/commands/fever_api/authentication.rb b/app/commands/fever_api/authentication.rb new file mode 100644 index 000000000..1ff89b28f --- /dev/null +++ b/app/commands/fever_api/authentication.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module FeverAPI::Authentication + def self.call(authorization:, **_params) + feeds = authorization.scope(Feed) + last_refreshed_on_time = (feeds.maximum(:last_fetched) || 0).to_i + + { auth: 1, last_refreshed_on_time: } + end +end diff --git a/app/commands/fever_api/read_favicons.rb b/app/commands/fever_api/read_favicons.rb new file mode 100644 index 000000000..2227d9376 --- /dev/null +++ b/app/commands/fever_api/read_favicons.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class FeverAPI::ReadFavicons + ICON = "R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" + + class << self + def call(params) + if params.key?(:favicons) + { favicons: } + else + {} + end + end + + private + + def favicons + [ + { + id: 0, + data: "image/gif;base64,#{ICON}" + } + ] + end + end +end diff --git a/app/commands/fever_api/read_feeds.rb b/app/commands/fever_api/read_feeds.rb new file mode 100644 index 000000000..2cf674999 --- /dev/null +++ b/app/commands/fever_api/read_feeds.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module FeverAPI::ReadFeeds + class << self + def call(authorization:, **params) + if params.key?(:feeds) + { feeds: feeds(authorization) } + else + {} + end + end + + private + + def feeds(authorization) + authorization.scope(FeedRepository.list).map(&:as_fever_json) + end + end +end diff --git a/app/commands/fever_api/read_feeds_groups.rb b/app/commands/fever_api/read_feeds_groups.rb new file mode 100644 index 000000000..c751ac9aa --- /dev/null +++ b/app/commands/fever_api/read_feeds_groups.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class FeverAPI::ReadFeedsGroups + class << self + def call(authorization:, **params) + if params.key?(:feeds) || params.key?(:groups) + { feeds_groups: feeds_groups(authorization) } + else + {} + end + end + + private + + def feeds_groups(authorization) + scoped_feeds = authorization.scope(FeedRepository.list) + grouped_feeds = scoped_feeds.order("LOWER(name)").group_by(&:group_id) + grouped_feeds.map do |group_id, feeds| + group_id ||= Group::UNGROUPED.id + + { + group_id:, + feed_ids: feeds.map(&:id).join(",") + } + end + end + end +end diff --git a/app/commands/fever_api/read_groups.rb b/app/commands/fever_api/read_groups.rb new file mode 100644 index 000000000..8fe316f10 --- /dev/null +++ b/app/commands/fever_api/read_groups.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module FeverAPI::ReadGroups + class << self + def call(authorization:, **params) + if params.key?(:groups) + { groups: groups(authorization) } + else + {} + end + end + + private + + def groups(authorization) + [Group::UNGROUPED, *authorization.scope(GroupRepository.list)] + .map(&:as_fever_json) + end + end +end diff --git a/app/commands/fever_api/read_items.rb b/app/commands/fever_api/read_items.rb new file mode 100644 index 000000000..eca1d15c7 --- /dev/null +++ b/app/commands/fever_api/read_items.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module FeverAPI::ReadItems + class << self + def call(authorization:, **params) + return {} unless params.key?(:items) + + item_ids = params[:with_ids].split(",") if params.key?(:with_ids) + + { + items: items(item_ids, params[:since_id], authorization), + total_items: total_items(item_ids, authorization) + } + end + + private + + def items(item_ids, since_id, authorization) + items = + if item_ids + stories_by_ids(item_ids, authorization) + else + unread_stories(since_id, authorization) + end + items.order(:published, :id).map(&:as_fever_json) + end + + def total_items(item_ids, authorization) + items = + if item_ids + stories_by_ids(item_ids, authorization) + else + unread_stories(nil, authorization) + end + items.count + end + + def stories_by_ids(ids, authorization) + authorization.scope(StoryRepository.fetch_by_ids(ids)) + end + + def unread_stories(since_id, authorization) + if since_id.present? + authorization.scope(StoryRepository.unread_since_id(since_id)) + else + authorization.scope(StoryRepository.unread) + end + end + end +end diff --git a/app/commands/fever_api/read_links.rb b/app/commands/fever_api/read_links.rb new file mode 100644 index 000000000..db5c0fbaf --- /dev/null +++ b/app/commands/fever_api/read_links.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module FeverAPI::ReadLinks + def self.call(params) + if params.key?(:links) + { links: [] } + else + {} + end + end +end diff --git a/app/commands/fever_api/response.rb b/app/commands/fever_api/response.rb new file mode 100644 index 000000000..55c7e699e --- /dev/null +++ b/app/commands/fever_api/response.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module FeverAPI::Response + ACTIONS = [ + FeverAPI::Authentication, + FeverAPI::ReadFeeds, + FeverAPI::ReadGroups, + FeverAPI::ReadFeedsGroups, + FeverAPI::ReadFavicons, + FeverAPI::ReadItems, + FeverAPI::ReadLinks, + FeverAPI::SyncUnreadItemIds, + FeverAPI::SyncSavedItemIds, + FeverAPI::WriteMarkItem, + FeverAPI::WriteMarkFeed, + FeverAPI::WriteMarkGroup + ].freeze + + def self.call(params) + result = { api_version: FeverAPI::API_VERSION } + ACTIONS.each { |action| result.merge!(action.call(**params)) } + result.to_json + end +end diff --git a/app/commands/fever_api/sync_saved_item_ids.rb b/app/commands/fever_api/sync_saved_item_ids.rb new file mode 100644 index 000000000..0a0a160a7 --- /dev/null +++ b/app/commands/fever_api/sync_saved_item_ids.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module FeverAPI::SyncSavedItemIds + class << self + def call(authorization:, **params) + if params.key?(:saved_item_ids) + { saved_item_ids: saved_item_ids(authorization) } + else + {} + end + end + + private + + def saved_item_ids(authorization) + all_starred_stories(authorization).map(&:id).join(",") + end + + def all_starred_stories(authorization) + authorization.scope(StoryRepository.all_starred) + end + end +end diff --git a/app/commands/fever_api/sync_unread_item_ids.rb b/app/commands/fever_api/sync_unread_item_ids.rb new file mode 100644 index 000000000..9f6351d2e --- /dev/null +++ b/app/commands/fever_api/sync_unread_item_ids.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module FeverAPI::SyncUnreadItemIds + class << self + def call(authorization:, **params) + if params.key?(:unread_item_ids) + { unread_item_ids: unread_item_ids(authorization) } + else + {} + end + end + + private + + def unread_item_ids(authorization) + authorization.scope(unread_stories).map(&:id).join(",") + end + + def unread_stories + StoryRepository.unread + end + end +end diff --git a/app/commands/fever_api/write_mark_feed.rb b/app/commands/fever_api/write_mark_feed.rb new file mode 100644 index 000000000..d01147078 --- /dev/null +++ b/app/commands/fever_api/write_mark_feed.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FeverAPI::WriteMarkFeed + def self.call(authorization:, **params) + if params[:mark] == "feed" + authorization.check(Feed.find(params[:id])) + MarkFeedAsRead.call(params[:id], params[:before]) + end + + {} + end +end diff --git a/app/commands/fever_api/write_mark_group.rb b/app/commands/fever_api/write_mark_group.rb new file mode 100644 index 000000000..bf285a820 --- /dev/null +++ b/app/commands/fever_api/write_mark_group.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FeverAPI::WriteMarkGroup + def self.call(authorization:, **params) + if params[:mark] == "group" + authorization.check(Group.find(params[:id])) + MarkGroupAsRead.call(params[:id], params[:before]) + end + + {} + end +end diff --git a/app/commands/fever_api/write_mark_item.rb b/app/commands/fever_api/write_mark_item.rb new file mode 100644 index 000000000..9550ace4b --- /dev/null +++ b/app/commands/fever_api/write_mark_item.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module FeverAPI::WriteMarkItem + class << self + def call(authorization:, **params) + if params[:mark] == "item" + authorization.check(Story.find(params[:id])) if params.key?(:id) + mark_item_as(params[:id], params[:as]) + end + + {} + end + + private + + def mark_item_as(id, mark_as) + case mark_as + when "read" + MarkAsRead.call(id) + when "unread" + MarkAsUnread.call(id) + when "saved" + MarkAsStarred.call(id) + when "unsaved" + MarkAsUnstarred.call(id) + end + end + end +end diff --git a/app/commands/stories/mark_all_as_read.rb b/app/commands/stories/mark_all_as_read.rb deleted file mode 100644 index e458c1f02..000000000 --- a/app/commands/stories/mark_all_as_read.rb +++ /dev/null @@ -1,12 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkAllAsRead - def initialize(story_ids, repository = StoryRepository) - @story_ids = story_ids - @repo = repository - end - - def mark_as_read - @repo.fetch_by_ids(@story_ids).update_all(is_read: true) - end -end diff --git a/app/commands/stories/mark_as_read.rb b/app/commands/stories/mark_as_read.rb deleted file mode 100644 index 93fb679ae..000000000 --- a/app/commands/stories/mark_as_read.rb +++ /dev/null @@ -1,12 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkAsRead - def initialize(story_id, repository = StoryRepository) - @story_id = story_id - @repo = repository - end - - def mark_as_read - @repo.fetch(@story_id).update_attributes(is_read: true) - end -end diff --git a/app/commands/stories/mark_as_starred.rb b/app/commands/stories/mark_as_starred.rb deleted file mode 100644 index aff11e67f..000000000 --- a/app/commands/stories/mark_as_starred.rb +++ /dev/null @@ -1,12 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkAsStarred - def initialize(story_id, repository = StoryRepository) - @story_id = story_id - @repo = repository - end - - def mark_as_starred - @repo.fetch(@story_id).update_attributes(is_starred: true) - end -end diff --git a/app/commands/stories/mark_as_unread.rb b/app/commands/stories/mark_as_unread.rb deleted file mode 100644 index d1d90fb3c..000000000 --- a/app/commands/stories/mark_as_unread.rb +++ /dev/null @@ -1,12 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkAsUnread - def initialize(story_id, repository = StoryRepository) - @story_id = story_id - @repo = repository - end - - def mark_as_unread - @repo.fetch(@story_id).update_attributes(is_read: false) - end -end diff --git a/app/commands/stories/mark_as_unstarred.rb b/app/commands/stories/mark_as_unstarred.rb deleted file mode 100644 index f6e6afe9c..000000000 --- a/app/commands/stories/mark_as_unstarred.rb +++ /dev/null @@ -1,12 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkAsUnstarred - def initialize(story_id, repository = StoryRepository) - @story_id = story_id - @repo = repository - end - - def mark_as_unstarred - @repo.fetch(@story_id).update_attributes(is_starred: false) - end -end diff --git a/app/commands/stories/mark_feed_as_read.rb b/app/commands/stories/mark_feed_as_read.rb deleted file mode 100644 index 035f50e1d..000000000 --- a/app/commands/stories/mark_feed_as_read.rb +++ /dev/null @@ -1,13 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkFeedAsRead - def initialize(feed_id, timestamp, repository = StoryRepository) - @feed_id = feed_id.to_i - @repo = repository - @timestamp = timestamp - end - - def mark_feed_as_read - @repo.fetch_unread_for_feed_by_timestamp(@feed_id, @timestamp).update_all(is_read: true) - end -end diff --git a/app/commands/stories/mark_group_as_read.rb b/app/commands/stories/mark_group_as_read.rb deleted file mode 100644 index 286b0821c..000000000 --- a/app/commands/stories/mark_group_as_read.rb +++ /dev/null @@ -1,22 +0,0 @@ -require_relative "../../repositories/story_repository" - -class MarkGroupAsRead - KINDLING_GROUP_ID = 0 - SPARKS_GROUP_ID = -1 - - def initialize(group_id, timestamp, repository = StoryRepository) - @group_id = group_id - @repo = repository - @timestamp = timestamp - end - - def mark_group_as_read - return unless @group_id - - if [KINDLING_GROUP_ID, SPARKS_GROUP_ID].include?(@group_id.to_i) - @repo.fetch_unread_by_timestamp(@timestamp).update_all(is_read: true) - elsif @group_id.to_i > 0 - @repo.fetch_unread_by_timestamp_and_group(@timestamp, @group_id).update_all(is_read: true) - end - end -end diff --git a/app/commands/story/mark_all_as_read.rb b/app/commands/story/mark_all_as_read.rb new file mode 100644 index 000000000..c20e49518 --- /dev/null +++ b/app/commands/story/mark_all_as_read.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MarkAllAsRead + def self.call(story_ids) + StoryRepository.fetch_by_ids(story_ids).update_all(is_read: true) + end +end diff --git a/app/commands/story/mark_as_read.rb b/app/commands/story/mark_as_read.rb new file mode 100644 index 000000000..27f7ab751 --- /dev/null +++ b/app/commands/story/mark_as_read.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MarkAsRead + def self.call(story_id) + StoryRepository.fetch(story_id).update!(is_read: true) + end +end diff --git a/app/commands/story/mark_as_starred.rb b/app/commands/story/mark_as_starred.rb new file mode 100644 index 000000000..aae2198a5 --- /dev/null +++ b/app/commands/story/mark_as_starred.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MarkAsStarred + def self.call(story_id) + StoryRepository.fetch(story_id).update!(is_starred: true) + end +end diff --git a/app/commands/story/mark_as_unread.rb b/app/commands/story/mark_as_unread.rb new file mode 100644 index 000000000..cf72798a9 --- /dev/null +++ b/app/commands/story/mark_as_unread.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MarkAsUnread + def self.call(story_id) + StoryRepository.fetch(story_id).update!(is_read: false) + end +end diff --git a/app/commands/story/mark_as_unstarred.rb b/app/commands/story/mark_as_unstarred.rb new file mode 100644 index 000000000..144abc19c --- /dev/null +++ b/app/commands/story/mark_as_unstarred.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module MarkAsUnstarred + def self.call(story_id) + StoryRepository.fetch(story_id).update!(is_starred: false) + end +end diff --git a/app/commands/story/mark_feed_as_read.rb b/app/commands/story/mark_feed_as_read.rb new file mode 100644 index 000000000..de3afa936 --- /dev/null +++ b/app/commands/story/mark_feed_as_read.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module MarkFeedAsRead + def self.call(feed_id, timestamp) + StoryRepository + .fetch_unread_for_feed_by_timestamp(feed_id, timestamp) + .update_all(is_read: true) + end +end diff --git a/app/commands/story/mark_group_as_read.rb b/app/commands/story/mark_group_as_read.rb new file mode 100644 index 000000000..9104c6238 --- /dev/null +++ b/app/commands/story/mark_group_as_read.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MarkGroupAsRead + KINDLING_GROUP_ID = 0 + SPARKS_GROUP_ID = -1 + + def self.call(group_id, timestamp) + return unless group_id + + if [KINDLING_GROUP_ID, SPARKS_GROUP_ID].include?(group_id.to_i) + StoryRepository + .fetch_unread_by_timestamp(timestamp).update_all(is_read: true) + else + StoryRepository + .fetch_unread_by_timestamp_and_group(timestamp, group_id) + .update_all(is_read: true) + end + end +end diff --git a/app/commands/user/sign_in_user.rb b/app/commands/user/sign_in_user.rb new file mode 100644 index 000000000..021ec10b6 --- /dev/null +++ b/app/commands/user/sign_in_user.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SignInUser + def self.call(username, submitted_password) + user = User.find_by(username:) + return unless user + + user_password = BCrypt::Password.new(user.password_digest) + + user if user_password == submitted_password + end +end diff --git a/app/commands/users/change_user_password.rb b/app/commands/users/change_user_password.rb deleted file mode 100644 index a818aea13..000000000 --- a/app/commands/users/change_user_password.rb +++ /dev/null @@ -1,17 +0,0 @@ -require_relative "../../repositories/user_repository" -require_relative "../../utils/api_key" - -class ChangeUserPassword - def initialize(repository = UserRepository) - @repo = repository - end - - def change_user_password(new_password) - user = @repo.first - user.password = user.password_confirmation = new_password - user.api_key = ApiKey.compute(new_password) - - @repo.save(user) - user - end -end diff --git a/app/commands/users/complete_setup.rb b/app/commands/users/complete_setup.rb deleted file mode 100644 index e37cb80bf..000000000 --- a/app/commands/users/complete_setup.rb +++ /dev/null @@ -1,7 +0,0 @@ -class CompleteSetup - def self.complete(user) - user.setup_complete = true - user.save - user - end -end diff --git a/app/commands/users/create_user.rb b/app/commands/users/create_user.rb deleted file mode 100644 index d3de6fe27..000000000 --- a/app/commands/users/create_user.rb +++ /dev/null @@ -1,15 +0,0 @@ -require_relative "../../utils/api_key" - -class CreateUser - def initialize(repository = User) - @repo = repository - end - - def create(password) - @repo.delete_all - @repo.create(password: password, - password_confirmation: password, - setup_complete: false, - api_key: ApiKey.compute(password)) - end -end diff --git a/app/commands/users/sign_in_user.rb b/app/commands/users/sign_in_user.rb deleted file mode 100644 index f41ad2e98..000000000 --- a/app/commands/users/sign_in_user.rb +++ /dev/null @@ -1,10 +0,0 @@ -require_relative "../../models/user" - -class SignInUser - def self.sign_in(submitted_password, repository = User) - user = repository.first - user_password = BCrypt::Password.new(user.password_digest) - - user if user_password == submitted_password - end -end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..2520b577b --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + before_action :complete_setup + before_action :authenticate_user + after_action -> { authorization.verify } + + private + + def authorization + @authorization ||= Authorization.new(current_user) + end + + def complete_setup + redirect_to("/setup/password") unless UserRepository.setup_complete? + end + + def authenticate_user + return if current_user + + session[:redirect_to] = request.fullpath + redirect_to("/login") + end + + def current_user + @current_user ||= UserRepository.fetch(session[:user_id]) + end + helper_method :current_user +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/debug_controller.rb b/app/controllers/debug_controller.rb index f38594201..bc87c11bb 100644 --- a/app/controllers/debug_controller.rb +++ b/app/controllers/debug_controller.rb @@ -1,14 +1,20 @@ -require_relative "../models/migration_status" +# frozen_string_literal: true -class Stringer < Sinatra::Base - get "/debug" do - erb :debug, locals: { - queued_jobs_count: Delayed::Job.count, - pending_migrations: MigrationStatus.new.pending_migrations - } +class DebugController < ApplicationController + skip_before_action :complete_setup, only: [:heroku] + skip_before_action :authenticate_user, only: [:heroku] + + def index + authorization.skip + render( + locals: { + queued_jobs_count: GoodJob::Job.queued.count, + pending_migrations: MigrationStatus.call + } + ) end - get "/heroku" do - erb :heroku + def heroku + authorization.skip end end diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb new file mode 100644 index 000000000..6faa78f0c --- /dev/null +++ b/app/controllers/exports_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ExportsController < ApplicationController + def index + xml = Feed::ExportToOpml.call(authorization.scope(Feed.all)) + + send_data( + xml, + type: "application/xml", + disposition: "attachment", + filename: "stringer.opml" + ) + end +end diff --git a/app/controllers/feeds_controller.rb b/app/controllers/feeds_controller.rb index 0cbb0edb7..b54de2d4e 100644 --- a/app/controllers/feeds_controller.rb +++ b/app/controllers/feeds_controller.rb @@ -1,72 +1,64 @@ -require_relative "../repositories/feed_repository" -require_relative "../commands/feeds/add_new_feed" -require_relative "../commands/feeds/export_to_opml" +# frozen_string_literal: true -class Stringer < Sinatra::Base - get "/feeds" do - @feeds = FeedRepository.list - - erb :'feeds/index' - end - - get "/feeds/:id/edit" do - @feed = FeedRepository.fetch(params[:id]) - - erb :'feeds/edit' +class FeedsController < ApplicationController + def index + @feeds = authorization.scope(FeedRepository.list.with_unread_stories_counts) end - put "/feeds/:id" do - feed = FeedRepository.fetch(params[:id]) - - FeedRepository.update_feed(feed, params[:feed_name], params[:feed_url], params[:group_id]) + def show + @feed = FeedRepository.fetch(params[:feed_id]) + authorization.check(@feed) - flash[:success] = t("feeds.edit.flash.updated_successfully") - redirect to("/feeds") + @stories = StoryRepository.feed(params[:feed_id]) + @unread_stories = @stories.reject(&:is_read) end - delete "/feeds/:feed_id" do - FeedRepository.delete(params[:feed_id]) - - status 200 + def new + authorization.skip + @feed_url = params[:feed_url] end - get "/feeds/new" do - @feed_url = params[:feed_url] - erb :'feeds/add' + def edit + @feed = FeedRepository.fetch(params[:id]) + authorization.check(@feed) end - post "/feeds" do + def create + authorization.skip @feed_url = params[:feed_url] - feed = AddNewFeed.add(@feed_url) + feed = Feed::Create.call(@feed_url, user: current_user) if feed && feed.valid? - FetchFeeds.enqueue([feed]) + CallableJob.perform_later(Feed::FetchOne, feed) - flash[:success] = t("feeds.add.flash.added_successfully") - redirect to("/") - elsif feed - flash.now[:error] = t("feeds.add.flash.already_subscribed_error") - erb :'feeds/add' + redirect_to("/", flash: { success: t(".success") }) else - flash.now[:error] = t("feeds.add.flash.feed_not_found_error") - erb :'feeds/add' + flash.now[:error] = feed ? t(".already_subscribed") : t(".feed_not_found") + + render(:new) end end - get "/feeds/import" do - erb :'feeds/import' - end + def update + feed = FeedRepository.fetch(params[:id]) + authorization.check(feed) - post "/feeds/import" do - ImportFromOpml.import(params["opml_file"][:tempfile].read) + FeedRepository.update_feed( + feed, + params[:feed_name], + params[:feed_url], + params[:group_id] + ) - redirect to("/setup/tutorial") + flash[:success] = t("feeds.edit.flash.updated_successfully") + redirect_to("/feeds") end - get "/feeds/export" do - content_type "application/xml" - attachment "stringer.opml" + def destroy + authorization.check(Feed.find(params[:id])) + FeedRepository.delete(params[:id]) - ExportToOpml.new(Feed.all).to_xml + flash[:success] = t(".success") + redirect_to("/feeds") end end diff --git a/app/controllers/fever_controller.rb b/app/controllers/fever_controller.rb new file mode 100644 index 000000000..bc3b59adc --- /dev/null +++ b/app/controllers/fever_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class FeverController < ApplicationController + skip_before_action :complete_setup, only: [:index, :update] + protect_from_forgery with: :null_session, only: [:update] + + def index + authorization.skip + render(json: FeverAPI::Response.call(fever_params)) + end + + def update + authorization.skip + render(json: FeverAPI::Response.call(fever_params)) + end + + private + + def fever_params + params.permit(FeverAPI::PARAMS).to_hash.symbolize_keys.merge(authorization:) + end + + def authenticate_user + return if current_user + + render(json: { api_version: FeverAPI::API_VERSION, auth: 0 }) + end + + def current_user + if instance_variable_defined?(:@current_user) + @current_user + else + @current_user = User.find_by(api_key: params[:api_key]) + end + end +end diff --git a/app/controllers/first_run_controller.rb b/app/controllers/first_run_controller.rb deleted file mode 100644 index 6be12f769..000000000 --- a/app/controllers/first_run_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -require_relative "../commands/feeds/import_from_opml" -require_relative "../commands/users/create_user" -require_relative "../commands/users/complete_setup" -require_relative "../repositories/user_repository" -require_relative "../repositories/story_repository" -require_relative "../tasks/fetch_feeds" - -class Stringer < Sinatra::Base - namespace "/setup" do - before do - redirect to("/news") if UserRepository.setup_complete? - end - - get "/password" do - erb :"first_run/password" - end - - post "/password" do - if no_password(params) || password_mismatch?(params) - flash.now[:error] = t("first_run.password.flash.passwords_dont_match") - erb :"first_run/password" - else - user = CreateUser.new.create(params[:password]) - session[:user_id] = user.id - - redirect to("/feeds/import") - end - end - - get "/tutorial" do - FetchFeeds.enqueue(Feed.all) - CompleteSetup.complete(current_user) - - @sample_stories = StoryRepository.samples - erb :tutorial - end - end - - private - - def no_password(params) - params[:password].nil? || params[:password] == "" - end - - def password_mismatch?(params) - params[:password] != params[:password_confirmation] - end -end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb new file mode 100644 index 000000000..c8887548c --- /dev/null +++ b/app/controllers/imports_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ImportsController < ApplicationController + def new + authorization.skip + end + + def create + authorization.skip + Feed::ImportFromOpml.call(params["opml_file"].read, user: current_user) + + redirect_to("/setup/tutorial") + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..0b1644439 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class PasswordsController < ApplicationController + skip_before_action :complete_setup, only: [:new, :create] + skip_before_action :authenticate_user, only: [:new, :create] + before_action :check_signups_enabled, only: [:new, :create] + + def new + authorization.skip + end + + def create + authorization.skip + + user = User.new(user_params) + if user.save + session[:user_id] = user.id + + redirect_to("/feeds/import") + else + flash.now[:error] = user.error_messages + render(:new) + end + end + + def update + authorization.skip + + if current_user.update(password_params) + redirect_to("/news", flash: { success: t(".success") }) + else + flash.now[:error] = t(".failure", errors: current_user.error_messages) + render("profiles/edit", locals: { user: current_user }) + end + end + + private + + def check_signups_enabled + redirect_to(login_path) unless Setting::UserSignup.enabled? + end + + def password_params + params + .expect(user: [ + :password_challenge, + :password, + :password_confirmation + ]) + end + + def user_params + params + .expect(user: [:username, :password, :password_confirmation]) + .merge(admin: User.none?) + .to_h.symbolize_keys + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..7d80c917a --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ProfilesController < ApplicationController + def edit + authorization.skip + + render(locals: { user: current_user }) + end + + def update + authorization.skip + + if current_user.update(user_params) + redirect_to(news_path, flash: { success: t(".success") }) + else + errors = current_user.error_messages + flash.now[:error] = t(".failure", errors:) + render(:edit, locals: { user: current_user }) + end + end + + private + + def user_params + params.expect(user: [:username, :password_challenge, :stories_order]) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 54d40df42..a9fa82b36 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,27 +1,31 @@ -require_relative "../commands/users/sign_in_user" +# frozen_string_literal: true -class Stringer < Sinatra::Base - get "/login" do - erb :"sessions/new" +class SessionsController < ApplicationController + skip_before_action :authenticate_user, only: [:new, :create] + + def new + authorization.skip end - post "/login" do - user = SignInUser.sign_in(params[:password]) + def create + authorization.skip + user = SignInUser.call(params[:username], params[:password]) if user session[:user_id] = user.id redirect_uri = session.delete(:redirect_to) || "/" - redirect to(redirect_uri) + redirect_to(redirect_uri) else flash.now[:error] = t("sessions.new.flash.wrong_password") - erb :"sessions/new" + render(:new) end end - get "/logout" do + def destroy + authorization.skip flash[:success] = t("sessions.destroy.flash.logged_out_successfully") session[:user_id] = nil - redirect to("/") + redirect_to("/") end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb new file mode 100644 index 000000000..8452f4ca6 --- /dev/null +++ b/app/controllers/settings_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class SettingsController < ApplicationController + def index + authorization.skip + end + + def update + authorization.skip + + setting = Setting.find(params[:id]) + setting.update!(setting_params) + + redirect_to(settings_path) + end + + private + + def setting_params + params.expect(setting: [:enabled]) + end +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 052682046..d5b03747b 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -1,48 +1,33 @@ -require_relative "../repositories/story_repository" -require_relative "../commands/stories/mark_all_as_read" +# frozen_string_literal: true -class Stringer < Sinatra::Base - get "/news" do - @unread_stories = StoryRepository.unread - - erb :index +class StoriesController < ApplicationController + def index + order = current_user.stories_order + @unread_stories = authorization.scope(StoryRepository.unread(order:)) end - get "/feed/:feed_id" do - @feed = FeedRepository.fetch(params[:feed_id]) + def update + json_params = JSON.parse(request.body.read, symbolize_names: true) - @stories = StoryRepository.feed(params[:feed_id]) - @unread_stories = @stories.find_all { |story| !story.is_read } + story = authorization.check(StoryRepository.fetch(params[:id])) + story.update!(json_params.slice(:is_read, :is_starred, :keep_unread)) - erb :feed + head(:no_content) end - get "/archive" do - @read_stories = StoryRepository.read(params[:page]) + def mark_all_as_read + stories = authorization.scope(Story.where(id: params[:story_ids])) + MarkAllAsRead.call(stories.ids) - erb :archive + redirect_to("/news") end - get "/starred" do - @starred_stories = StoryRepository.starred(params[:page]) - - erb :starred + def archived + @read_stories = authorization.scope(StoryRepository.read(params[:page])) end - put "/stories/:id" do - json_params = JSON.parse(request.body.read, symbolize_names: true) - - story = StoryRepository.fetch(params[:id]) - story.is_read = !!json_params[:is_read] - story.keep_unread = !!json_params[:keep_unread] - story.is_starred = !!json_params[:is_starred] - - StoryRepository.save(story) - end - - post "/stories/mark_all_as_read" do - MarkAllAsRead.new(params[:story_ids]).mark_as_read - - redirect to("/news") + def starred + @starred_stories = + authorization.scope(StoryRepository.starred(params[:page])) end end diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb new file mode 100644 index 000000000..42c9328d3 --- /dev/null +++ b/app/controllers/tutorials_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TutorialsController < ApplicationController + def index + authorization.skip + CallableJob.perform_later(Feed::FetchAllForUser, current_user) + + @sample_stories = StoryRepository.samples + end +end diff --git a/app/fever_api/authentication.rb b/app/fever_api/authentication.rb deleted file mode 100644 index 819eb07eb..000000000 --- a/app/fever_api/authentication.rb +++ /dev/null @@ -1,11 +0,0 @@ -module FeverAPI - class Authentication - def initialize(options = {}) - @clock = options.fetch(:clock) { Time } - end - - def call(_params) - { auth: 1, last_refreshed_on_time: @clock.now.to_i } - end - end -end diff --git a/app/fever_api/read_favicons.rb b/app/fever_api/read_favicons.rb deleted file mode 100644 index f49393198..000000000 --- a/app/fever_api/read_favicons.rb +++ /dev/null @@ -1,22 +0,0 @@ -module FeverAPI - class ReadFavicons - def call(params = {}) - if params.keys.include?("favicons") - { favicons: favicons } - else - {} - end - end - - private - - def favicons - [ - { - id: 0, - data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" - } - ] - end - end -end diff --git a/app/fever_api/read_feeds.rb b/app/fever_api/read_feeds.rb deleted file mode 100644 index a00a4b60d..000000000 --- a/app/fever_api/read_feeds.rb +++ /dev/null @@ -1,23 +0,0 @@ -require_relative "../repositories/feed_repository" - -module FeverAPI - class ReadFeeds - def initialize(options = {}) - @feed_repository = options.fetch(:feed_repository) { FeedRepository } - end - - def call(params = {}) - if params.keys.include?("feeds") - { feeds: feeds } - else - {} - end - end - - private - - def feeds - @feed_repository.list.map(&:as_fever_json) - end - end -end diff --git a/app/fever_api/read_feeds_groups.rb b/app/fever_api/read_feeds_groups.rb deleted file mode 100644 index 6e6513b47..000000000 --- a/app/fever_api/read_feeds_groups.rb +++ /dev/null @@ -1,29 +0,0 @@ -require_relative "../repositories/feed_repository" - -module FeverAPI - class ReadFeedsGroups - def initialize(options = {}) - @feed_repository = options.fetch(:feed_repository) { FeedRepository } - end - - def call(params = {}) - if params.keys.include?("feeds") || params.keys.include?("groups") - { feeds_groups: feeds_groups } - else - {} - end - end - - private - - def feeds_groups - grouped_feeds = @feed_repository.in_group.order("LOWER(name)").group_by(&:group_id) - grouped_feeds.map do |group_id, feeds| - { - group_id: group_id, - feed_ids: feeds.map(&:id).join(",") - } - end - end - end -end diff --git a/app/fever_api/read_groups.rb b/app/fever_api/read_groups.rb deleted file mode 100644 index 2709e6648..000000000 --- a/app/fever_api/read_groups.rb +++ /dev/null @@ -1,23 +0,0 @@ -require_relative "../repositories/group_repository" - -module FeverAPI - class ReadGroups - def initialize(options = {}) - @group_repository = options.fetch(:group_repository) { GroupRepository } - end - - def call(params = {}) - if params.keys.include?("groups") - { groups: groups } - else - {} - end - end - - private - - def groups - @group_repository.list.map(&:as_fever_json) - end - end -end diff --git a/app/fever_api/read_items.rb b/app/fever_api/read_items.rb deleted file mode 100644 index 0ae45ffbd..000000000 --- a/app/fever_api/read_items.rb +++ /dev/null @@ -1,50 +0,0 @@ -require_relative "../repositories/story_repository" - -module FeverAPI - class ReadItems - def initialize(options = {}) - @story_repository = options.fetch(:story_repository) { StoryRepository } - end - - def call(params = {}) - if params.keys.include?("items") - item_ids = begin - params[:with_ids].split(",") - rescue - nil - end - - { - items: items(item_ids, params[:since_id]), - total_items: total_items(item_ids) - } - else - {} - end - end - - private - - def items(item_ids, since_id) - items = item_ids ? stories_by_ids(item_ids) : unread_stories(since_id) - items.map(&:as_fever_json) - end - - def total_items(item_ids) - items = item_ids ? stories_by_ids(item_ids) : unread_stories - items.count - end - - def stories_by_ids(ids) - @story_repository.fetch_by_ids(ids) - end - - def unread_stories(since_id = nil) - if since_id - @story_repository.unread_since_id(since_id) - else - @story_repository.unread - end - end - end -end diff --git a/app/fever_api/read_links.rb b/app/fever_api/read_links.rb deleted file mode 100644 index 595343c9a..000000000 --- a/app/fever_api/read_links.rb +++ /dev/null @@ -1,17 +0,0 @@ -module FeverAPI - class ReadLinks - def call(params = {}) - if params.keys.include?("links") - { links: links } - else - {} - end - end - - private - - def links - [] - end - end -end diff --git a/app/fever_api/response.rb b/app/fever_api/response.rb deleted file mode 100644 index 1c9521060..000000000 --- a/app/fever_api/response.rb +++ /dev/null @@ -1,50 +0,0 @@ -require_relative "authentication" - -require_relative "read_groups" -require_relative "read_feeds" -require_relative "read_feeds_groups" -require_relative "read_favicons" -require_relative "read_items" -require_relative "read_links" - -require_relative "sync_unread_item_ids" -require_relative "sync_saved_item_ids" - -require_relative "write_mark_item" -require_relative "write_mark_feed" -require_relative "write_mark_group" - -module FeverAPI - API_VERSION = 3 - - class Response - ACTIONS = [ - Authentication, - - ReadFeeds, - ReadGroups, - ReadFeedsGroups, - ReadFavicons, - ReadItems, - ReadLinks, - - SyncUnreadItemIds, - SyncSavedItemIds, - - WriteMarkItem, - WriteMarkFeed, - WriteMarkGroup - ].freeze - - def initialize(params) - @params = params - end - - def to_json - base_response = { api_version: API_VERSION } - ACTIONS - .inject(base_response) { |a, e| a.merge!(e.new.call(@params)) } - .to_json - end - end -end diff --git a/app/fever_api/sync_saved_item_ids.rb b/app/fever_api/sync_saved_item_ids.rb deleted file mode 100644 index 9e4ff9dde..000000000 --- a/app/fever_api/sync_saved_item_ids.rb +++ /dev/null @@ -1,27 +0,0 @@ -require_relative "../repositories/story_repository" - -module FeverAPI - class SyncSavedItemIds - def initialize(options = {}) - @story_repository = options.fetch(:story_repository) { StoryRepository } - end - - def call(params = {}) - if params.keys.include?("saved_item_ids") - { saved_item_ids: saved_item_ids } - else - {} - end - end - - private - - def saved_item_ids - all_starred_stories.map(&:id).join(",") - end - - def all_starred_stories - @story_repository.all_starred - end - end -end diff --git a/app/fever_api/sync_unread_item_ids.rb b/app/fever_api/sync_unread_item_ids.rb deleted file mode 100644 index 7d469d73b..000000000 --- a/app/fever_api/sync_unread_item_ids.rb +++ /dev/null @@ -1,27 +0,0 @@ -require_relative "../repositories/story_repository" - -module FeverAPI - class SyncUnreadItemIds - def initialize(options = {}) - @story_repository = options.fetch(:story_repository) { StoryRepository } - end - - def call(params = {}) - if params.keys.include?("unread_item_ids") - { unread_item_ids: unread_item_ids } - else - {} - end - end - - private - - def unread_item_ids - unread_stories.map(&:id).join(",") - end - - def unread_stories - @story_repository.unread - end - end -end diff --git a/app/fever_api/write_mark_feed.rb b/app/fever_api/write_mark_feed.rb deleted file mode 100644 index 1c4d72961..000000000 --- a/app/fever_api/write_mark_feed.rb +++ /dev/null @@ -1,15 +0,0 @@ -require_relative "../commands/stories/mark_feed_as_read" - -module FeverAPI - class WriteMarkFeed - def initialize(options = {}) - @marker_class = options.fetch(:marker_class) { MarkFeedAsRead } - end - - def call(params = {}) - @marker_class.new(params[:id], params[:before]).mark_feed_as_read if params[:mark] == "feed" - - {} - end - end -end diff --git a/app/fever_api/write_mark_group.rb b/app/fever_api/write_mark_group.rb deleted file mode 100644 index 3ef39a0e1..000000000 --- a/app/fever_api/write_mark_group.rb +++ /dev/null @@ -1,15 +0,0 @@ -require_relative "../commands/stories/mark_group_as_read" - -module FeverAPI - class WriteMarkGroup - def initialize(options = {}) - @marker_class = options.fetch(:marker_class) { MarkGroupAsRead } - end - - def call(params = {}) - @marker_class.new(params[:id], params[:before]).mark_group_as_read if params[:mark] == "group" - - {} - end - end -end diff --git a/app/fever_api/write_mark_item.rb b/app/fever_api/write_mark_item.rb deleted file mode 100644 index a2752e013..000000000 --- a/app/fever_api/write_mark_item.rb +++ /dev/null @@ -1,36 +0,0 @@ -require_relative "../commands/stories/mark_as_read" -require_relative "../commands/stories/mark_as_unread" -require_relative "../commands/stories/mark_as_starred" -require_relative "../commands/stories/mark_as_unstarred" - -module FeverAPI - class WriteMarkItem - def initialize(options = {}) - @read_marker_class = options.fetch(:read_marker_class) { MarkAsRead } - @unread_marker_class = options.fetch(:unread_marker_class) { MarkAsUnread } - @starred_marker_class = options.fetch(:starred_marker_class) { MarkAsStarred } - @unstarred_marker_class = options.fetch(:unstarred_marker_class) { MarkAsUnstarred } - end - - def call(params = {}) - mark_item_as(params[:id], params[:as]) if params[:mark] == "item" - - {} - end - - private - - def mark_item_as(id, as) - case as - when "read" - @read_marker_class.new(id).mark_as_read - when "unread" - @unread_marker_class.new(id).mark_as_unread - when "saved" - @starred_marker_class.new(id).mark_as_starred - when "unsaved" - @unstarred_marker_class.new(id).mark_as_unstarred - end - end - end -end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb deleted file mode 100644 index 9239b5d63..000000000 --- a/app/helpers/authentication_helpers.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "sinatra/base" - -require_relative "../repositories/user_repository" - -module Sinatra - module AuthenticationHelpers - def authenticated? - session[:user_id] - end - - def needs_authentication?(path) - return false if ENV["RACK_ENV"] == "test" - return false unless UserRepository.setup_complete? - return false if %w(/login /logout /heroku).include?(path) - return false if path =~ /css|js|img/ - true - end - - def current_user - UserRepository.fetch(session[:user_id]) - end - end -end diff --git a/app/helpers/url_helpers.rb b/app/helpers/url_helpers.rb index 9d4c6fc7e..a17ee89cd 100644 --- a/app/helpers/url_helpers.rb +++ b/app/helpers/url_helpers.rb @@ -1,23 +1,18 @@ -require "nokogiri" -require "uri" +# frozen_string_literal: true module UrlHelpers - ABS_RE = URI::DEFAULT_PARSER.regexp[:ABS_URI] - def expand_absolute_urls(content, base_url) doc = Nokogiri::HTML.fragment(content) - [%w(a href), %w(img src), %w(video src)].each do |tag, attr| + [["a", "href"], ["img", "src"], ["video", "src"]].each do |tag, attr| doc.css("#{tag}[#{attr}]").each do |node| url = node.get_attribute(attr) - next if url =~ ABS_RE + next if url =~ URI::RFC2396_PARSER.regexp[:ABS_URI] - begin - node.set_attribute(attr, URI.join(base_url, url).to_s) - rescue URI::InvalidURIError # rubocop:disable Lint/HandleExceptions - # Just ignore. If we cannot parse the url, we don't want the entire - # import to blow up. - end + node.set_attribute(attr, URI.join(base_url, url).to_s) + rescue URI::InvalidURIError + # Just ignore. If we cannot parse the url, we don't want the entire + # import to blow up. end end @@ -25,11 +20,11 @@ def expand_absolute_urls(content, base_url) end def normalize_url(url, base_url) - uri = URI.parse(url) + uri = URI.parse(url.strip) # resolve (protocol) relative URIs if uri.relative? - base_uri = URI.parse(base_url) + base_uri = URI.parse(base_url.strip) scheme = base_uri.scheme || "http" uri = URI.join("#{scheme}://#{base_uri.host}", uri) end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d92ffddcb --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationJob < ActiveJob::Base +end diff --git a/app/jobs/callable_job.rb b/app/jobs/callable_job.rb new file mode 100644 index 000000000..3225d6dd1 --- /dev/null +++ b/app/jobs/callable_job.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CallableJob < ApplicationJob + def perform(callable, *, **) + callable.call(*, **) + end +end diff --git a/app/jobs/fetch_feed_job.rb b/app/jobs/fetch_feed_job.rb deleted file mode 100644 index 4f5915a1b..000000000 --- a/app/jobs/fetch_feed_job.rb +++ /dev/null @@ -1,6 +0,0 @@ -FetchFeedJob = Struct.new(:feed_id) do - def perform - feed = FeedRepository.fetch(feed_id) - FetchFeed.new(feed).fetch - end -end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..866db6833 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class + + def self.boolean_accessor(attribute, key, default: false) + store_accessor(attribute, key) + + define_method(key) do + value = super() + value.nil? ? default : CastBoolean.call(value) + end + alias_method(:"#{key}?", :"#{key}") + + define_method(:"#{key}=") do |value| + super(value.nil? ? default : CastBoolean.call(value)) + end + end + + def error_messages + errors.full_messages.join(", ") + end +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/models/feed.rb b/app/models/feed.rb index 08b66523b..17ffca406 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -1,34 +1,37 @@ -class Feed < ActiveRecord::Base - has_many :stories, -> { order "published desc" }, dependent: :delete_all +# frozen_string_literal: true + +class Feed < ApplicationRecord + has_many :stories, -> { order(published: :desc) }, dependent: :delete_all + has_many :unread_stories, -> { unread }, class_name: "Story" belongs_to :group + belongs_to :user - validates_uniqueness_of :url + delegate :name, to: :group, prefix: true, allow_nil: true - STATUS = { green: 0, yellow: 1, red: 2 }.freeze + validates :url, presence: true, uniqueness: { scope: :user_id } + validates :user_id, presence: true - def status - STATUS.key(read_attribute(:status)) - end + enum :status, { green: 0, yellow: 1, red: 2 } - def status=(s) - write_attribute(:status, STATUS[s]) - end + scope :with_unread_stories_counts, + lambda { + left_joins(:unread_stories) + .select("feeds.*, count(stories.id) as unread_stories_count") + .group("feeds.id") + } def status_bubble - return :yellow if status == :red && stories.any? - status - end + return "yellow" if status == "red" && stories.any? - def unread_stories - stories.where("is_read = ?", false) + status end def as_fever_json { - id: id, + id:, favicon_id: 0, - title: name, - url: url, + title: name || "", + url:, site_url: url, is_spark: 0, last_updated_on_time: last_fetched.to_i diff --git a/app/models/group.rb b/app/models/group.rb index 7c26fcd54..26ebe1ddd 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,7 +1,15 @@ -class Group < ActiveRecord::Base +# frozen_string_literal: true + +class Group < ApplicationRecord + UNGROUPED = Group.new(id: 0, name: "Ungrouped") + + belongs_to :user has_many :feeds + validates :name, presence: true, uniqueness: { scope: :user_id } + validates :user_id, presence: true + def as_fever_json - { id: id, title: name } + { id:, title: name } end end diff --git a/app/models/migration_status.rb b/app/models/migration_status.rb index b89956cbe..c9b0f618f 100644 --- a/app/models/migration_status.rb +++ b/app/models/migration_status.rb @@ -1,17 +1,11 @@ -class MigrationStatus - attr_reader :migrator +# frozen_string_literal: true - def initialize(migrator = ActiveRecord::Migrator) - @migrator = migrator - end - - def pending_migrations - migrations_path = migrator.migrations_path - migrations = migrator.migrations(migrations_path) - current_version = migrator.current_version +module MigrationStatus + def self.call + migrator = ActiveRecord::Base.connection.pool.migration_context.open - migrations - .select { |m| current_version < m.version } - .map { |m| "#{m.name} - #{m.version}" } + migrator.pending_migrations.map do |migration| + "#{migration.name} - #{migration.version}" + end end end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 000000000..cb38322ec --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Setting < ApplicationRecord + validates :type, presence: true, uniqueness: true +end diff --git a/app/models/setting/user_signup.rb b/app/models/setting/user_signup.rb new file mode 100644 index 000000000..d1ec03f81 --- /dev/null +++ b/app/models/setting/user_signup.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Setting::UserSignup < Setting + boolean_accessor :data, :enabled, default: false + + validates :enabled, inclusion: { in: [true, false] } + + def self.first + first_or_create! + end + + def self.enabled? + first_or_create!.enabled? || User.none? + end +end diff --git a/app/models/story.rb b/app/models/story.rb index 6dec0471d..3806fafe1 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -1,11 +1,16 @@ -require_relative "./feed" +# frozen_string_literal: true -class Story < ActiveRecord::Base +class Story < ApplicationRecord belongs_to :feed + has_one :user, through: :feed validates_uniqueness_of :entry_id, scope: :feed_id - UNTITLED = "[untitled]".freeze + delegate :group_id, :user_id, to: :feed + + scope :unread, -> { where(is_read: false) } + + UNTITLED = "[untitled]" def headline title.nil? ? UNTITLED : strip_html(title)[0, 50] @@ -29,9 +34,9 @@ def as_json(_options = {}) def as_fever_json { - id: id, - feed_id: feed_id, - title: title, + id:, + feed_id:, + title:, author: source, html: body, url: permalink, diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 000000000..6c001351e --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Subscription < ApplicationRecord + belongs_to :user + + STATUSES = ["active", "past_due", "unpaid", "canceled"].freeze + + validates :user_id, presence: true, uniqueness: true + validates :stripe_customer_id, presence: true, uniqueness: true + validates :stripe_subscription_id, presence: true, uniqueness: true + validates :status, presence: true, inclusion: { in: STATUSES } + validates :current_period_start, presence: true + validates :current_period_end, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 13422ce7f..587a6cc93 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,45 @@ -class User < ActiveRecord::Base +# frozen_string_literal: true + +class User < ApplicationRecord has_secure_password - def setup_complete? - setup_complete + encrypts :api_key, deterministic: true + + has_many :feeds, dependent: :delete_all + has_many :groups, dependent: :delete_all + + validates :username, presence: true, uniqueness: { case_sensitive: false } + validate :password_challenge_matches + + before_save :update_api_key + + enum :stories_order, { desc: "desc", asc: "asc" }, prefix: true + + attr_accessor :password_challenge + + # `password_challenge` logic should be able to be removed in Rails 7.1 + # https://blog.appsignal.com/2023/02/15/whats-new-in-rails-7-1.html#password-challenge-via-has_secure_password + def password_challenge_matches + return unless password_challenge + + digested_password = BCrypt::Password.new(password_digest_was) + return if digested_password.is_password?(password_challenge) + + errors.add(:original_password, "does not match") + end + + def update_api_key + return unless password_digest_changed? || username_changed? + + if password_challenge.blank? && password.blank? + message = "Cannot change username without providing a password" + + raise(ActiveRecord::ActiveRecordError, message) + end + + password = password_challenge.presence || self.password.presence + + # API key based on Fever spec: https://feedafever.com/api + self.api_key = Digest::MD5.hexdigest("#{username}:#{password}") end end diff --git a/app/repositories/feed_repository.rb b/app/repositories/feed_repository.rb index 6a8bec620..f28585c8b 100644 --- a/app/repositories/feed_repository.rb +++ b/app/repositories/feed_repository.rb @@ -1,4 +1,4 @@ -require_relative "../models/feed" +# frozen_string_literal: true class FeedRepository MIN_YEAR = 1970 @@ -19,10 +19,10 @@ def self.update_feed(feed, name, url, group_id = nil) end def self.update_last_fetched(feed, timestamp) - if valid_timestamp?(timestamp, feed.last_fetched) - feed.last_fetched = timestamp - feed.save - end + return unless valid_timestamp?(timestamp, feed.last_fetched) + + feed.last_fetched = timestamp + feed.save end def self.delete(feed_id) @@ -35,11 +35,11 @@ def self.set_status(status, feed) end def self.list - Feed.order("lower(name)") + Feed.order(Feed.arel_table[:name].lower) end def self.in_group - Feed.where("group_id IS NOT NULL") + Feed.where.not(group_id: nil) end def self.valid_timestamp?(new_timestamp, current_timestamp) diff --git a/app/repositories/group_repository.rb b/app/repositories/group_repository.rb index 61f30a914..39635fec0 100644 --- a/app/repositories/group_repository.rb +++ b/app/repositories/group_repository.rb @@ -1,7 +1,7 @@ -require_relative "../models/group" +# frozen_string_literal: true class GroupRepository def self.list - Group.order("LOWER(name)") + Group.order(Group.arel_table[:name].lower) end end diff --git a/app/repositories/story_repository.rb b/app/repositories/story_repository.rb index 4967d77ac..b9eda7efb 100644 --- a/app/repositories/story_repository.rb +++ b/app/repositories/story_repository.rb @@ -1,19 +1,21 @@ -require_relative "../helpers/url_helpers" -require_relative "../models/story" -require_relative "../utils/sample_story" +# frozen_string_literal: true class StoryRepository extend UrlHelpers def self.add(entry, feed) - Story.create(feed: feed, - title: extract_title(entry), - permalink: extract_url(entry, feed), - body: extract_content(entry), - is_read: false, - is_starred: false, - published: entry.published || Time.now, - entry_id: entry.id) + enclosure_url = entry.enclosure_url if entry.respond_to?(:enclosure_url) + Story.create( + feed:, + title: extract_title(entry), + permalink: extract_url(entry, feed), + enclosure_url:, + body: extract_content(entry), + is_read: false, + is_starred: false, + published: entry.published || Time.now, + entry_id: entry.id + ) end def self.fetch(id) @@ -26,55 +28,54 @@ def self.fetch_by_ids(ids) def self.fetch_unread_by_timestamp(timestamp) timestamp = Time.at(timestamp.to_i) - Story.where("stories.created_at < ?", timestamp).where(is_read: false) + Story.where(stories: { created_at: ...timestamp }).where(is_read: false) end def self.fetch_unread_by_timestamp_and_group(timestamp, group_id) - fetch_unread_by_timestamp(timestamp).joins(:feed).where(feeds: { group_id: group_id }) + fetch_unread_by_timestamp(timestamp) + .joins(:feed).where(feeds: { group_id: }) end def self.fetch_unread_for_feed_by_timestamp(feed_id, timestamp) timestamp = Time.at(timestamp.to_i) - Story.where(feed_id: feed_id).where("created_at < ? AND is_read = ?", timestamp, false) - end - - def self.save(story) - story.save + Story + .where(feed_id:) + .where("created_at < ? AND is_read = ?", timestamp, false) end def self.exists?(id, feed_id) - Story.exists?(entry_id: id, feed_id: feed_id) + Story.exists?(entry_id: id, feed_id:) end - def self.unread - Story.where(is_read: false).order("published desc").includes(:feed) + def self.unread(order: "desc") + Story.where(is_read: false).order("published #{order}").includes(:feed) end def self.unread_since_id(since_id) - unread.where("id > ?", since_id) + unread.where(Story.arel_table[:id].gt(since_id)) end def self.feed(feed_id) - Story.where("feed_id = ?", feed_id).order("published desc").includes(:feed) + Story.where(feed_id:).order(published: :desc).includes(:feed) end def self.read(page = 1) Story.where(is_read: true).includes(:feed) - .order("published desc").page(page).per_page(20) + .order(published: :desc).page(page).per_page(20) end def self.starred(page = 1) Story.where(is_starred: true).includes(:feed) - .order("published desc").page(page).per_page(20) + .order(published: :desc).page(page).per_page(20) end def self.all_starred - Story.where(is_starred: true) + Story.where(is_starred: true).order(published: :desc) end def self.unstarred_read_stories_older_than(num_days) Story.where(is_read: true, is_starred: false) - .where("published <= ?", num_days.days.ago) + .where(published: ..num_days.days.ago) end def self.read_count @@ -82,40 +83,41 @@ def self.read_count end def self.extract_url(entry, feed) - return entry.enclosure_url if entry.url.nil? && entry.enclosure_url.present? + return normalize_url(entry.url, feed.url) if entry.url.present? - normalize_url(entry.url, feed.url) unless entry.url.nil? + entry.enclosure_url if entry.respond_to?(:enclosure_url) end def self.extract_content(entry) sanitized_content = "" - if entry.content - sanitized_content = sanitize(entry.content) - elsif entry.summary - sanitized_content = sanitize(entry.summary) - end + content = entry.content || entry.summary + sanitized_content = ContentSanitizer.call(content) if content - expand_absolute_urls(sanitized_content, entry.url) + if entry.url.present? + expand_absolute_urls(sanitized_content, entry.url) + else + sanitized_content + end end def self.extract_title(entry) - return sanitize(entry.title) if entry.title.present? - return sanitize(entry.summary) if entry.summary.present? - "There isn't a title for this story" - end + return ContentSanitizer.call(entry.title) if entry.title.present? + return ContentSanitizer.call(entry.summary) if entry.summary.present? - def self.sanitize(content) - Loofah.fragment(content.gsub(//i, "")) - .scrub!(:prune) - .scrub!(:unprintable) - .to_s + "There isn't a title for this story" end def self.samples [ - SampleStory.new("Darin' Fireballs", "Why you should trade your firstborn for a Retina iPad"), - SampleStory.new("TechKrunch", "SugarGlidr raises $1.2M Series A for Social Network for Photo Filters"), + SampleStory.new( + "Darin' Fireballs", + "Why you should trade your firstborn for a Retina iPad" + ), + SampleStory.new( + "TechKrunch", + "SugarGlidr raises $1.2M Series A for Social Network for Photo Filters" + ), SampleStory.new("Lambda Da Ultimate", "Flimsy types are the new hotness") ] end diff --git a/app/repositories/user_repository.rb b/app/repositories/user_repository.rb index 029f26b85..1120839ba 100644 --- a/app/repositories/user_repository.rb +++ b/app/repositories/user_repository.rb @@ -1,4 +1,4 @@ -require_relative "../models/user" +# frozen_string_literal: true class UserRepository def self.fetch(id) @@ -8,7 +8,7 @@ def self.fetch(id) end def self.setup_complete? - User.any? && User.first.setup_complete? + User.any? end def self.save(user) diff --git a/app/tasks/change_password.rb b/app/tasks/change_password.rb deleted file mode 100644 index 8a3f67011..000000000 --- a/app/tasks/change_password.rb +++ /dev/null @@ -1,33 +0,0 @@ -require "io/console" - -require_relative "../commands/users/change_user_password" - -class ChangePassword - def initialize(command = ChangeUserPassword.new) - @command = command - end - - def change_password - while (password = ask_password) != ask_confirmation - puts "The confirmation doesn't match the password. Please try again." - end - @command.change_user_password(password) - end - - private - - def ask_password - ask_hidden("New password: ") - end - - def ask_confirmation - ask_hidden("Confirmation: ") - end - - def ask_hidden(question) - print(question) - input = STDIN.noecho(&:gets).chomp - puts - input - end -end diff --git a/app/tasks/fetch_feed.rb b/app/tasks/fetch_feed.rb deleted file mode 100644 index 2482391cc..000000000 --- a/app/tasks/fetch_feed.rb +++ /dev/null @@ -1,56 +0,0 @@ -require "feedjira" - -require_relative "../repositories/story_repository" -require_relative "../repositories/feed_repository" -require_relative "../commands/feeds/find_new_stories" - -class FetchFeed - def initialize(feed, parser: Feedjira::Feed, logger: nil) - @feed = feed - @parser = parser - @logger = logger - end - - def fetch - raw_feed = fetch_raw_feed - - if raw_feed == 304 - feed_not_modified - else - feed_modified(raw_feed) - end - - FeedRepository.set_status(:green, @feed) - rescue => ex - FeedRepository.set_status(:red, @feed) - - @logger.error "Something went wrong when parsing #{@feed.url}: #{ex}" if @logger - end - - private - - def fetch_raw_feed - @parser.fetch_and_parse(@feed.url) - end - - def feed_not_modified - @logger.info "#{@feed.url} has not been modified since last fetch" if @logger - end - - def feed_modified(raw_feed) - new_entries_from(raw_feed).each do |entry| - StoryRepository.add(entry, @feed) - end - - FeedRepository.update_last_fetched(@feed, raw_feed.last_modified) - end - - def new_entries_from(raw_feed) - finder = FindNewStories.new(raw_feed, @feed.id, @feed.last_fetched, latest_entry_id) - finder.new_stories - end - - def latest_entry_id - return @feed.stories.first.entry_id unless @feed.stories.empty? - end -end diff --git a/app/tasks/fetch_feeds.rb b/app/tasks/fetch_feeds.rb deleted file mode 100644 index 73cfc2c68..000000000 --- a/app/tasks/fetch_feeds.rb +++ /dev/null @@ -1,37 +0,0 @@ -require "thread/pool" - -require_relative "fetch_feed" - -class FetchFeeds - def initialize(feeds, pool = nil) - @pool = pool - @feeds = feeds - @feeds_ids = [] - end - - def fetch_all - @pool ||= Thread.pool(10) - - @feeds = FeedRepository.fetch_by_ids(@feeds_ids) if @feeds.blank? && !@feeds_ids.blank? - - @feeds.each do |feed| - @pool.process do - FetchFeed.new(feed).fetch - - ActiveRecord::Base.connection.close - end - end - - @pool.shutdown - end - - def prepare_to_delay - @feeds_ids = @feeds.map(&:id) - @feeds = [] - self - end - - def self.enqueue(feeds) - new(feeds).prepare_to_delay.delay.fetch_all - end -end diff --git a/app/tasks/remove_old_stories.rb b/app/tasks/remove_old_stories.rb index d766b49bd..e51bbbd2e 100644 --- a/app/tasks/remove_old_stories.rb +++ b/app/tasks/remove_old_stories.rb @@ -1,17 +1,23 @@ -class RemoveOldStories - def self.remove!(number_of_days) - stories = old_stories(number_of_days) - feeds = pruned_feeds(stories) +# frozen_string_literal: true - stories.delete_all - feeds.each { |feed| FeedRepository.update_last_fetched(feed, Time.now) } - end +module RemoveOldStories + class << self + def call(number_of_days) + stories = old_stories(number_of_days) + feeds = pruned_feeds(stories) - def self.old_stories(number_of_days) - StoryRepository.unstarred_read_stories_older_than(number_of_days) - end + stories.delete_all + feeds.each { |feed| FeedRepository.update_last_fetched(feed, Time.now) } + end + + private + + def old_stories(number_of_days) + StoryRepository.unstarred_read_stories_older_than(number_of_days) + end - def self.pruned_feeds(stories) - FeedRepository.fetch_by_ids(stories.map(&:feed_id)) + def pruned_feeds(stories) + FeedRepository.fetch_by_ids(stories.map(&:feed_id)) + end end end diff --git a/app/utils/api_key.rb b/app/utils/api_key.rb deleted file mode 100644 index 6f0f52c4c..000000000 --- a/app/utils/api_key.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "digest/md5" - -class ApiKey - def self.compute(plaintext_password) - Digest::MD5.hexdigest("stringer:#{plaintext_password}") - end -end diff --git a/app/utils/authorization.rb b/app/utils/authorization.rb new file mode 100644 index 000000000..bd417d05d --- /dev/null +++ b/app/utils/authorization.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Authorization + attr_accessor :user, :authorized + + class NotAuthorizedError < StandardError; end + + def initialize(user) + self.user = user + self.authorized = false + end + + alias authorized? authorized + + def check(record) + raise(NotAuthorizedError) unless record.user_id == user.id + + self.authorized = true + record + end + + def scope(records) + self.authorized = true + records.joins(:user).where(users: { id: user.id }) + end + + def skip + self.authorized = true + end + + def verify + raise(NotAuthorizedError) unless authorized? + end +end diff --git a/app/utils/content_sanitizer.rb b/app/utils/content_sanitizer.rb new file mode 100644 index 000000000..e7fd83d6b --- /dev/null +++ b/app/utils/content_sanitizer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ContentSanitizer + def self.call(content) + Loofah.fragment(content.gsub(//i, "")) + .scrub!(:prune) + .scrub!(:unprintable) + .to_s + end +end diff --git a/app/utils/feed_discovery.rb b/app/utils/feed_discovery.rb index 8c8be46d9..2664a09ee 100644 --- a/app/utils/feed_discovery.rb +++ b/app/utils/feed_discovery.rb @@ -1,23 +1,25 @@ -require "feedbag" -require "feedjira" +# frozen_string_literal: true -class FeedDiscovery - def discover(url, finder = Feedbag, parser = Feedjira::Feed) - get_feed_for_url(url, parser) do - urls = finder.find(url) - return false if urls.empty? +module FeedDiscovery + class << self + def call(url) + get_feed_for_url(url) do + urls = Feedbag.find(url) + return false if urls.empty? - get_feed_for_url(urls.first, parser) do - return false + get_feed_for_url(urls.first) { return false } end end - end - def get_feed_for_url(url, parser) - feed = parser.fetch_and_parse(url) - feed.feed_url ||= url - feed - rescue - yield if block_given? + private + + def get_feed_for_url(url) + response = HTTParty.get(url).to_s + feed = Feedjira.parse(response) + feed.feed_url ||= url + feed + rescue StandardError + yield + end end end diff --git a/app/utils/opml_parser.rb b/app/utils/opml_parser.rb index e1643f68c..2d25b16f7 100644 --- a/app/utils/opml_parser.rb +++ b/app/utils/opml_parser.rb @@ -1,44 +1,48 @@ +# frozen_string_literal: true + require "nokogiri" -class OpmlParser - def parse_feeds(contents) - feeds_with_groups = Hash.new { |h, k| h[k] = [] } - - outlines_in(contents).each do |outline| - if outline_is_group?(outline) - group_name = extract_name(outline.attributes).value - feeds = outline.xpath("./outline") - else # it's a top-level feed, which means it's a feed without group - group_name = "Ungrouped" - feeds = [outline] +module OpmlParser + class << self + def call(contents) + feeds_with_groups = Hash.new { |h, k| h[k] = [] } + + outlines_in(contents).each do |outline| + if outline_is_group?(outline) + group_name = extract_name(outline.attributes).value + feeds = outline.xpath("./outline") + else # it's a top-level feed, which means it's a feed without group + group_name = "Ungrouped" + feeds = [outline] + end + + feeds.each do |feed| + feeds_with_groups[group_name] << feed_to_hash(feed) + end end - feeds.each do |feed| - feeds_with_groups[group_name] << feed_to_hash(feed) - end + feeds_with_groups end - feeds_with_groups - end - - private + private - def outlines_in(contents) - Nokogiri.XML(contents).xpath("//body/outline") - end + def outlines_in(contents) + Nokogiri.XML(contents).xpath("//body/outline") + end - def outline_is_group?(outline) - outline.attributes["xmlUrl"].nil? - end + def outline_is_group?(outline) + outline.attributes["xmlUrl"].nil? + end - def extract_name(attributes) - attributes["title"] || attributes["text"] - end + def extract_name(attributes) + attributes["title"] || attributes["text"] + end - def feed_to_hash(feed) - { - name: extract_name(feed.attributes).value, - url: feed.attributes["xmlUrl"].value - } + def feed_to_hash(feed) + { + name: extract_name(feed.attributes)&.value, + url: feed.attributes["xmlUrl"].value + } + end end end diff --git a/app/utils/sample_story.rb b/app/utils/sample_story.rb index 9fa5ae58e..437eda401 100644 --- a/app/utils/sample_story.rb +++ b/app/utils/sample_story.rb @@ -1,74 +1,80 @@ -SampleStory = Struct.new(:source, :title, :lead, :is_read, :published) do - BODY = <<-eos.freeze -

Tofu shoreditch intelligentsia umami, fashion axe photo booth -try-hard terry richardson quinoa actually fingerstache meggings fixie. Aesthetic -salvia vinyl raw denim, keffiyeh master cleanse tonx selfies mlkshk occupy twee -street art gentrify. Quinoa PBR readymade 90's. Chambray Austin aesthetic -meggings, carles vinyl intelligentsia tattooed. Keffiyeh mumblecore -fingerstache, sartorial sriracha disrupt biodiesel cred. Skateboard yr cosby -sweater, narwhal beard ethnic jean shorts aesthetic. Post-ironic flannel mlkshk, -pickled VHS wolf banjo forage portland wayfarers.

- -

Selfies mumblecore odd future irony DIY messenger bag. -Authentic neutra next level selvage squid. Four loko freegan occupy, tousled -vinyl leggings selvage messenger bag. Four loko wayfarers kale chips, next level -banksy banh mi umami flannel hella. Street art odd future scenester, -intelligentsia brunch fingerstache YOLO narwhal single-origin coffee tousled -tumblr pop-up four loko you probably haven't heard of them dreamcatcher. -Single-origin coffee direct trade retro biodiesel, truffaut fanny pack portland -blue bottle scenester bushwick. Skateboard squid fanny pack bushwick, photo -booth vice literally.

- eos +# frozen_string_literal: true - def id - -1 * rand(100) - end +SAMPLE_BODY = <<~HTML +

Tofu shoreditch intelligentsia umami, fashion axe photo booth + try-hard terry richardson quinoa actually fingerstache meggings fixie. Aesthetic + salvia vinyl raw denim, keffiyeh master cleanse tonx selfies mlkshk occupy twee + street art gentrify. Quinoa PBR readymade 90's. Chambray Austin aesthetic + meggings, carles vinyl intelligentsia tattooed. Keffiyeh mumblecore + fingerstache, sartorial sriracha disrupt biodiesel cred. Skateboard yr cosby + sweater, narwhal beard ethnic jean shorts aesthetic. Post-ironic flannel mlkshk, + pickled VHS wolf banjo forage portland wayfarers.

+ +

Selfies mumblecore odd future irony DIY messenger bag. + Authentic neutra next level selvage squid. Four loko freegan occupy, tousled + vinyl leggings selvage messenger bag. Four loko wayfarers kale chips, next level + banksy banh mi umami flannel hella. Street art odd future scenester, + intelligentsia brunch fingerstache YOLO narwhal single-origin coffee tousled + tumblr pop-up four loko you probably haven't heard of them dreamcatcher. + Single-origin coffee direct trade retro biodiesel, truffaut fanny pack portland + blue bottle scenester bushwick. Skateboard squid fanny pack bushwick, photo + booth vice literally.

+HTML - def headline - title - end +SampleStory = + Struct.new(:source, :title, :lead, :is_read, :published) do + def id + rand(100) * -1 + end - def permalink - "#" - end + def headline + title + end - def lead - "Tofu shoreditch intelligentsia umami, fashion axe photo booth try-hard" - end + def permalink + "#" + end - def body - BODY - end + def enclosure_url; end - def is_read # rubocop:disable Style/PredicateName - false - end + def lead + "Tofu shoreditch intelligentsia umami, fashion axe photo booth try-hard" + end - def keep_unread - false - end + def body + SAMPLE_BODY + end - def is_starred # rubocop:disable Style/PredicateName - false - end + def is_read + false + end - def published - Time.now - end + def keep_unread + false + end + + def is_starred + false + end + + def published + Time.now + end - def as_json(_options = {}) - { - id: id, - headline: headline, - lead: lead, - source: source, - title: title, - pretty_date: published.strftime("%A, %B %d"), - body: body, - permalink: permalink, - is_read: is_read, - is_starred: is_starred, - keep_unread: keep_unread - } + def as_json(_options = {}) + { + id:, + headline:, + lead:, + source:, + title:, + pretty_date: published.strftime("%A, %B %d"), + body:, + enclosure_url:, + permalink:, + is_read:, + is_starred:, + keep_unread: + } + end end -end diff --git a/app/views/heroku.erb b/app/views/debug/heroku.html.erb similarity index 100% rename from app/views/heroku.erb rename to app/views/debug/heroku.html.erb diff --git a/app/views/debug.erb b/app/views/debug/index.html.erb similarity index 100% rename from app/views/debug.erb rename to app/views/debug/index.html.erb diff --git a/app/views/partials/_feed_action_bar.erb b/app/views/feeds/_action_bar.html.erb similarity index 86% rename from app/views/partials/_feed_action_bar.erb rename to app/views/feeds/_action_bar.html.erb index f11a3d0c4..80e32b674 100644 --- a/app/views/partials/_feed_action_bar.erb +++ b/app/views/feeds/_action_bar.html.erb @@ -1,11 +1,15 @@ -
-
+ diff --git a/app/views/feeds/_feed.html.erb b/app/views/feeds/_feed.html.erb new file mode 100644 index 000000000..99bf270a3 --- /dev/null +++ b/app/views/feeds/_feed.html.erb @@ -0,0 +1,36 @@ +
  • +
    + +
    +
    + <%= t('partials.feed.last_updated') %> + + <% if feed.last_fetched %> + <%= I18n.l(feed.last_fetched) %> + <% else %> + <%= t("partials.feed.last_fetched.never") %> + <% end %> + +
    + + + + + "> + + <%= button_to("/feeds/#{feed.id}", method: :delete, class: "remove-feed btn-link", aria: { label: "Delete" }) do %> + + <% end %> +
    +
    +
  • diff --git a/app/views/partials/_action_bar.erb b/app/views/feeds/_single_feed_action_bar.html.erb similarity index 81% rename from app/views/partials/_action_bar.erb rename to app/views/feeds/_single_feed_action_bar.html.erb index 523e6e2da..580db37a7 100644 --- a/app/views/partials/_action_bar.erb +++ b/app/views/feeds/_single_feed_action_bar.html.erb @@ -1,11 +1,13 @@
    @@ -39,11 +41,6 @@ if (refresh) refresh.click(); }); - Mousetrap.bind("f", function() { - var all_feeds = $("a#feeds")[0]; - if (all_feeds) all_feeds.click(); - }); - Mousetrap.bind("a", function() { var add_feed = $("a#add-feed")[0]; if (add_feed) add_feed.click(); diff --git a/app/views/feeds/edit.erb b/app/views/feeds/edit.erb deleted file mode 100644 index 7f0565daf..000000000 --- a/app/views/feeds/edit.erb +++ /dev/null @@ -1,36 +0,0 @@ -
    - <%= render_partial :feed_action_bar %> -
    - -
    -

    <%= @feed.name %>

    -
    -
    - - -
    - - - -
    -
    - - - -
    - <% if Group.any? %> -
    - - - -
    - <% end %> - - -
    -
    diff --git a/app/views/feeds/edit.html.erb b/app/views/feeds/edit.html.erb new file mode 100644 index 000000000..a932c3e4f --- /dev/null +++ b/app/views/feeds/edit.html.erb @@ -0,0 +1,35 @@ +
    + <%= render "feeds/action_bar" %> +
    + +
    +

    <%= @feed.name %>

    +
    + <%= form_with(url: "/feeds/#{@feed.id}", method: :put, id: "add-feed-setup") do %> + +
    + + + +
    +
    + + + +
    + <% if current_user.groups.any? %> +
    + + + +
    + <% end %> + + + <% end %> +
    diff --git a/app/views/feeds/index.erb b/app/views/feeds/index.html.erb similarity index 72% rename from app/views/feeds/index.erb rename to app/views/feeds/index.html.erb index 0dad10031..79025c862 100644 --- a/app/views/feeds/index.erb +++ b/app/views/feeds/index.html.erb @@ -1,18 +1,18 @@
    - <%= render_partial :feed_action_bar %> + <%= render "feeds/action_bar" %>
    <% unless @feeds.empty? %>
      <% @feeds.each do |feed| %> - <%= render_partial :feed, { feed: feed } %> + <%= render "feeds/feed", { feed: feed } %> <% end %>
    <% else %>
    -

    <%= t('feeds.index.add_some_feeds', :add => ''+t('feeds.index.add')+'') %>

    +

    <%= t('feeds.index.add_some_feeds', :add => ''+t('feeds.index.add')+'').html_safe %>

    <% end %> @@ -20,4 +20,4 @@ $(document).ready(function () { $(".status").tooltip(); }); - \ No newline at end of file + diff --git a/app/views/feeds/add.erb b/app/views/feeds/new.html.erb similarity index 69% rename from app/views/feeds/add.erb rename to app/views/feeds/new.html.erb index 7ead0fde7..5f36a8575 100644 --- a/app/views/feeds/add.erb +++ b/app/views/feeds/new.html.erb @@ -1,5 +1,5 @@
    - <%= render_partial :feed_action_bar %> + <%= render "feeds/action_bar" %>
    @@ -7,13 +7,13 @@

    <%= t('feeds.add.description') %>


    -
    + <%= form_with(url: "/feeds", id: "add-feed-setup") do %>
    - +
    -
    + <% end %>
    diff --git a/app/views/feed.erb b/app/views/feeds/show.html.erb similarity index 67% rename from app/views/feed.erb rename to app/views/feeds/show.html.erb index d6576b5e4..8a539df9a 100644 --- a/app/views/feed.erb +++ b/app/views/feeds/show.html.erb @@ -5,14 +5,14 @@ <% end %>
    - <%= render_partial :single_feed_action_bar, { stories: @unread_stories } %> + <%= render "feeds/single_feed_action_bar", { stories: @unread_stories } %>
    <%= @feed.name %>
    -<%= render_js :stories, { stories: @stories } %> +<%= render "stories/js", { stories: @stories } %>
      diff --git a/app/views/first_run/password.erb b/app/views/first_run/password.erb deleted file mode 100644 index 90a3d6394..000000000 --- a/app/views/first_run/password.erb +++ /dev/null @@ -1,22 +0,0 @@ -
      -

      <%= t('first_run.password.title') %> <%= t('first_run.password.anti_social') %>.

      -

      <%= t('first_run.password.subtitle') %>

      -
      -

      <%= t('first_run.password.description') %>

      -
      -
      - -
      - - - -
      -
      - - - -
      - - -
      -
      diff --git a/app/views/feeds/import.erb b/app/views/imports/new.html.erb similarity index 59% rename from app/views/feeds/import.erb rename to app/views/imports/new.html.erb index 35632da9c..ba7c7246a 100644 --- a/app/views/feeds/import.erb +++ b/app/views/imports/new.html.erb @@ -1,17 +1,23 @@ + +

      <%= t('import.title') %>

      <%= t('import.subtitle') %>


      - <%= t('import.description', :link => ''+t('import.export')+'') %> + <%= t('import.description') %>


      -
      - + <%= form_with(url: "/feeds/import", id: "import", multipart: true) do %> + -
      + <% end %>
      \ No newline at end of file + diff --git a/app/views/partials/_footer.erb b/app/views/layouts/_footer.html.erb similarity index 50% rename from app/views/partials/_footer.erb rename to app/views/layouts/_footer.html.erb index 5f5619930..076efed8b 100644 --- a/app/views/partials/_footer.erb +++ b/app/views/layouts/_footer.html.erb @@ -1,6 +1,6 @@
      -
      +
      -
      +

      <%= t('layout.hey') %> <%= t('layout.back_to_work') %>

      diff --git a/app/views/partials/_shortcuts.erb b/app/views/layouts/_shortcuts.html.erb similarity index 100% rename from app/views/partials/_shortcuts.erb rename to app/views/layouts/_shortcuts.html.erb diff --git a/app/views/layout.erb b/app/views/layouts/application.html.erb similarity index 61% rename from app/views/layout.erb rename to app/views/layouts/application.html.erb index 980aee3ae..638cf0819 100644 --- a/app/views/layout.erb +++ b/app/views/layouts/application.html.erb @@ -1,25 +1,31 @@ - + - <%= yield_content :title %> + <%= content_for(:title) %> <%= t('layout.title') %> + <%= csrf_meta_tags %> - <%= yield_content :head %> + - - + + + + <%= content_for(:head) %> + + <%= stylesheet_link_tag 'application' %> + <%= javascript_include_tag 'application' %>
      - <%= render_partial :flash %> - <%= render_partial :shortcuts if current_user %> + <%= render 'layouts/flash' %> + <%= render 'layouts/shortcuts' if current_user %>
      @@ -33,7 +39,7 @@
      diff --git a/app/views/partials/_story.erb b/app/views/partials/_story.erb deleted file mode 100644 index 19926f492..000000000 --- a/app/views/partials/_story.erb +++ /dev/null @@ -1,46 +0,0 @@ -<% if story.is_read %> -
    • -<% else %> -
    • -<% end %> -
      -
      -

      - <%= story.source %> -

      -
      -
      -

      - - <%= story.headline %> - - - — <%= story.lead %> - -

      -
      -
      - - - -
    • \ No newline at end of file diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 000000000..87c46ce47 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,27 @@ +
      +

      <%= t('first_run.password.title') %> <%= t('first_run.password.anti_social') %>.

      +

      <%= t('first_run.password.subtitle') %>

      +
      +

      <%= t('first_run.password.description') %>

      +
      + <%= form_with(model: User.new, url: "/setup/password", id: "password_setup") do |form| %> +
      + <%= form.text_field :username, class: "form-control", required: true %> + <%= form.label :username, t('first_run.password.fields.username'), class: "field-label" %> +
      + +
      + <%= form.password_field :password, class: "form-control", required: true %> + + <%= form.label :password, t('first_run.password.fields.password'), class: "field-label" %> +
      + +
      + <%= form.password_field :password_confirmation, class: "form-control", required: true %> + + <%= form.label :password_confirmation, t('first_run.password.fields.password_confirmation'), class: "field-label" %> +
      + + + <% end %> +
      diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..d8bc7c499 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,37 @@ +

      <%= t('.title') %>

      + +<%= form_with(model: user, url: profile_path) do |form| %> +
      + <%= t(".stories_feed_settings") %> + <%= form.label :stories_order %> + <%= form.select :stories_order, User.stories_orders.transform_keys {|k| User.human_attribute_name("stories_order.#{k}") } %> +
      + <%= form.submit("Update") %> +<% end %> + +<%= form_with(model: user, url: profile_path) do |form| %> +
      + <%= t('.change_username') %> +
      +

      <%= t('.warning_html') %>

      +
      + <%= form.label :username %> + <%= form.text_field :username, required: true %> + <%= form.label :password_challenge, "Existing password" %> + <%= form.password_field :password_challenge, required: true %> +
      + <%= form.submit("Update username") %> +<% end %> + +<%= form_with(model: user, url: password_path) do |form| %> +
      + <%= t('.change_password') %> + <%= form.label :password_challenge, "Existing password" %> + <%= form.password_field :password_challenge, required: true %> + <%= form.label :password, "New password" %> + <%= form.password_field :password, required: true %> + <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation, required: true %> +
      + <%= form.submit("Update password") %> +<% end %> diff --git a/app/views/read.erb b/app/views/read.erb deleted file mode 100644 index b37ad9663..000000000 --- a/app/views/read.erb +++ /dev/null @@ -1,26 +0,0 @@ -
      - <%= render_partial :feed_action_bar %> -
      - -<% unless @read_stories.empty? %> -
      -
        - <% @read_stories.each do |story| %> - <%= render_partial :story, { story: story } %> - <% end %> -
      -
      - -<% else %> -
      -

      Sorry, you haven't read any stories yet!

      -
      -<% end %> \ No newline at end of file diff --git a/app/views/sessions/new.erb b/app/views/sessions/new.erb index 2a5bc8183..cb3d05e2f 100644 --- a/app/views/sessions/new.erb +++ b/app/views/sessions/new.erb @@ -4,13 +4,24 @@
      -
      - + <%= form_with(url: "/login") do |form| %> +

      <%= t('sessions.new.fields.unknown_username_html') %>

      - + <%= form.text_field :username, class: "form-control", required: true %> + <%= form.label :username, t('sessions.new.fields.username'), class: "field-label" %> +
      + +
      +
      -
      + <% end %> + + <% if Setting::UserSignup.enabled? %> +
      + <%= t('.sign_up_html', href: setup_password_path) %> +
      + <% end %>
      diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb new file mode 100644 index 000000000..9924c21eb --- /dev/null +++ b/app/views/settings/index.html.erb @@ -0,0 +1,24 @@ +
      + <%= render "feeds/action_bar" %> +
      + +
      +

      <%= t('.heading') %>

      +
      +

      <%= t('.description') %>

      +
      +
      + <% setting = Setting::UserSignup.first %> + <%= form_with(model: setting, scope: :setting, url: setting_path(setting)) do |form| %> + <% if Setting::UserSignup.enabled? %> + <%= form.hidden_field :enabled, value: false %> + <%= t('.signup.enabled') %> + <%= form.submit(t('.signup.disable'), class: 'btn btn-primary pull-right') %> + <% else %> + <%= form.hidden_field :enabled, value: true %> + <%= t('.signup.disabled') %> + <%= form.submit(t('.signup.enable'), class: 'btn btn-primary pull-right') %> + <% end %> + <% end %> +
      +
      diff --git a/app/views/stories/_action_bar.html.erb b/app/views/stories/_action_bar.html.erb new file mode 100644 index 000000000..6017a05ba --- /dev/null +++ b/app/views/stories/_action_bar.html.erb @@ -0,0 +1,59 @@ + + + diff --git a/app/views/js/stories.js.erb b/app/views/stories/_js.html.erb similarity index 90% rename from app/views/js/stories.js.erb rename to app/views/stories/_js.html.erb index f937cce8a..f804579e1 100644 --- a/app/views/js/stories.js.erb +++ b/app/views/stories/_js.html.erb @@ -1,10 +1,10 @@ -<%= render_js_template :story %> +<%= render 'stories/templates' %> diff --git a/app/views/partials/_zen.erb b/app/views/stories/_zen.html.erb similarity index 84% rename from app/views/partials/_zen.erb rename to app/views/stories/_zen.html.erb index 702e6dbe0..389a72f93 100644 --- a/app/views/partials/_zen.erb +++ b/app/views/stories/_zen.html.erb @@ -1,6 +1,6 @@
      – 0 – -

      <%= t('partials.zen.rss_zero') %>

      +

      <%= t('partials.zen.rss_zero').html_safe %>

      <%= t('partials.zen.gtfo') %> <%= t('partials.zen.go_make') %>. diff --git a/app/views/archive.erb b/app/views/stories/archived.html.erb similarity index 92% rename from app/views/archive.erb rename to app/views/stories/archived.html.erb index 263368fea..0c2a09def 100644 --- a/app/views/archive.erb +++ b/app/views/stories/archived.html.erb @@ -1,9 +1,9 @@

      - <%= render_partial :feed_action_bar %> + <%= render "feeds/action_bar" %>
      <% unless @read_stories.empty? %> - <%= render_js :stories, { stories: @read_stories } %> + <%= render "stories/js", { stories: @read_stories } %>
        @@ -49,4 +49,4 @@

        <%= t('archive.sorry') %>

        -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/index.erb b/app/views/stories/index.html.erb similarity index 61% rename from app/views/index.erb rename to app/views/stories/index.html.erb index 55a710b1b..619793cd9 100644 --- a/app/views/index.erb +++ b/app/views/stories/index.html.erb @@ -5,16 +5,16 @@ <% end %>
        - <%= render_partial :action_bar, { stories: @unread_stories } %> + <%= render "stories/action_bar", { stories: @unread_stories } %>
        <% if @unread_stories.empty? %> - <%= render_partial :zen %> + <%= render "stories/zen" %> <% else %> - <%= render_js :stories, { stories: @unread_stories } %> + <%= render "stories/js", { stories: @unread_stories } %>
        -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/starred.erb b/app/views/stories/starred.html.erb similarity index 92% rename from app/views/starred.erb rename to app/views/stories/starred.html.erb index 6335b92cd..65da0018f 100644 --- a/app/views/starred.erb +++ b/app/views/stories/starred.html.erb @@ -1,9 +1,9 @@
        - <%= render_partial :feed_action_bar %> + <%= render "feeds/action_bar" %>
        <% unless @starred_stories.empty? %> - <%= render_js :stories, { stories: @starred_stories } %> + <%= render "stories/js", { stories: @starred_stories } %>
          diff --git a/app/views/partials/_tutorial_action_bar.erb b/app/views/tutorials/_action_bar.html.erb similarity index 74% rename from app/views/partials/_tutorial_action_bar.erb rename to app/views/tutorials/_action_bar.html.erb index f5ed6d893..b61ffa136 100644 --- a/app/views/partials/_tutorial_action_bar.erb +++ b/app/views/tutorials/_action_bar.html.erb @@ -1,16 +1,19 @@ -
          -
          + diff --git a/app/views/tutorial.erb b/app/views/tutorials/index.html.erb similarity index 86% rename from app/views/tutorial.erb rename to app/views/tutorials/index.html.erb index 308918c89..d06a29046 100644 --- a/app/views/tutorial.erb +++ b/app/views/tutorials/index.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - + <%= stylesheet_link_tag 'reenie-beanie-font', media: 'all', 'data-turbolinks-track': 'reload' %> <% end %>
          @@ -19,10 +19,10 @@
          - <%= render_partial :tutorial_action_bar, {stories: @sample_stories} %> + <%= render 'tutorials/action_bar', {stories: @sample_stories} %>
          -<%= render_js :stories, { stories: @sample_stories } %> +<%= render 'stories/js', { stories: @sample_stories } %>
            diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..6981d9176 --- /dev/null +++ b/bin/dev @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +exec "./bin/rails", "server", *ARGV diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..22f2d8dee --- /dev/null +++ b/bin/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..e436ea54a --- /dev/null +++ b/bin/rake @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..25406fe04 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,10 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..2afb54f0d --- /dev/null +++ b/bin/setup @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*) + system(*, exception: true) +end + +FileUtils.chdir(APP_ROOT) do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + if ARGV.exclude?("--skip-server") + puts "\n== Starting development server ==" + $stdout.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..4b1980888 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru index 3720996aa..2e0308469 100644 --- a/config.ru +++ b/config.ru @@ -1,12 +1,8 @@ -require "rubygems" -require "bundler" +# frozen_string_literal: true -Bundler.require +# This file is used by Rack-based servers to start the application. -require "./fever_api" -map "/fever" do - run FeverAPI::Endpoint -end +require_relative "config/environment" -require "./app" -run Stringer +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..c9f63ac32 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +# require "action_cable/engine" +require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Stringer +end + +class Stringer::Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults(8.0) + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: ["assets", "tasks"]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + config.eager_load_paths << Rails.root.join("app/commands/story") + config.eager_load_paths << Rails.root.join("app/commands/user") + + # Don't generate system test files. + config.generators.system_tests = nil + + config.active_job.queue_adapter = :good_job + + config.active_record.belongs_to_required_by_default = false + + config.active_record.encryption.primary_key = + ENV.fetch("ENCRYPTION_PRIMARY_KEY") + config.active_record.encryption.deterministic_key = + ENV.fetch("ENCRYPTION_DETERMINISTIC_KEY") + config.active_record.encryption.key_derivation_salt = + ENV.fetch("ENCRYPTION_KEY_DERIVATION_SALT") +end diff --git a/config/asset_pipeline.rb b/config/asset_pipeline.rb deleted file mode 100644 index 73818fd89..000000000 --- a/config/asset_pipeline.rb +++ /dev/null @@ -1,53 +0,0 @@ -module AssetPipeline - def registered(app) - app.set :sprockets, Sprockets::Environment.new(app.root) - - app.get "/assets/*" do - env["PATH_INFO"].sub!(%r{^/assets}, "") - settings.sprockets.call(env) - end - - append_paths(app) - configure_development(app) - configure_production(app) - register_helpers(app) - end - - private - - def append_paths(app) - app.sprockets.append_path File.join(app.root, "app", "assets") - app.sprockets.append_path File.join(app.root, "app", "assets", "stylesheets") - app.sprockets.append_path File.join(app.root, "app", "assets", "javascripts") - end - - def configure_development(app) - app.configure :development do - app.sprockets.cache = Sprockets::Cache::FileStore.new("./tmp") - end - end - - def configure_production(app) - app.configure :production do - app.sprockets.css_compressor = :scss - app.sprockets.js_compressor = :uglify - end - end - - def register_helpers(app) - Sprockets::Helpers.configure do |config| - config.environment = app.sprockets - config.prefix = "/assets" - config.debug = true if app.development? - config.digest = true if app.production? - end - - app.helpers Sprockets::Helpers - end - - module_function :registered, - :append_paths, - :configure_development, - :configure_production, - :register_helpers -end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..c2241d707 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/config/database.yml b/config/database.yml index f1c394139..dfb4e4850 100644 --- a/config/database.yml +++ b/config/database.yml @@ -2,13 +2,17 @@ development: adapter: postgresql database: stringer_dev encoding: unicode + host: localhost pool: 5 test: - adapter: sqlite3 - database: db/stringer_test.sqlite + adapter: postgresql + database: stringer_test + encoding: unicode + host: localhost + pool: 5 production: url: <%= ENV["DATABASE_URL"] %> encoding: unicode - pool: 5 + pool: <%= Integer(ENV.fetch("DB_POOL", 15)) %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..7df99e89c --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..b0fa8757e --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in + # config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller + # caching is disabled. Run rails dev:cache to toggle Action Controller + # caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = + { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing + # actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..1f6af775f --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in + # config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored + # by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { + "cache-control" => "public, max-age=#{Integer(1.year)}" + } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Assume all access to the app is happening through a SSL-terminating reverse + # proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and + # use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = + # { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [:request_id] + config.logger = ActiveSupport::TaggedLogging.logger($stdout) + + # Change to "debug" to log everything (including potentially + # personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable + # alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active + # Job. + # config.active_job.queue_adapter = :resque + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [:id] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = + # { exclude: ->(request) { request.path == "/up" } } + + config.good_job.execution_mode = :async + + config.assets.compile = false +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..65fd7938e --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in + # config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test + # locally, this is usually not necessary, and can slow down your test suite. + # However, it's recommended that you enable it in continuous integration + # systems to ensure eager loading is working properly before deploying your + # code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = + { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other + # exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing + # actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..8544c07c5 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..665eb90b1 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and +# inline styles. +# config.content_security_policy_nonce_generator = +# ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/dotenv.rb b/config/initializers/dotenv.rb new file mode 100644 index 000000000..62d88ea0a --- /dev/null +++ b/config/initializers/dotenv.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Dotenv.require_keys( + "SECRET_KEY_BASE", + "ENCRYPTION_PRIMARY_KEY", + "ENCRYPTION_DETERMINISTIC_KEY", + "ENCRYPTION_KEY_DERIVATION_SALT" +) diff --git a/config/initializers/feed_bag.rb b/config/initializers/feed_bag.rb new file mode 100644 index 000000000..85b43affb --- /dev/null +++ b/config/initializers/feed_bag.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Feedbag.logger = Rails.logger diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..4a1a9a497 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) +# and filtered from the log file. Use this to limit dissemination of sensitive +# information. See the ActiveSupport::ParameterFilter documentation for +# supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, + :email, + :secret, + :token, + :_key, + :crypt, + :salt, + :certificate, + :otp, + :ssn, + :cvv, + :cvc +] diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 000000000..86eb3555a --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +GoodJob.preserve_job_records = false diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..b5f7c7c4e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym("API") } diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 000000000..e8d0b2ae8 --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Define an application-wide HTTP permissions policy. For further +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" +# end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 000000000..2aa04adc9 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +session_config = { key: "_stringer_session", expire_after: 2.weeks } +Rails.application.config.session_store(:cookie_store, **session_config) diff --git a/config/locales/de.yml b/config/locales/de.yml index bef897ecb..c2497e154 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -25,11 +25,11 @@ de: fields: feed_url: Feed URL submit: Hinzufügen - flash: - added_successfully: Wir haben deinen Feed hinzugefügt. Schau ein bisschen später wieder vorbei. - already_subscribed_error: Du hast diesen Feed bereits abonniert... - feed_not_found_error: Wir konnten diesen Feed nicht finden. Probiere es noch einmal title: Benötigst du neue Geschichten? + create: + success: Wir haben deinen Feed hinzugefügt. Schau ein bisschen später wieder vorbei. + already_subscribed: Du hast diesen Feed bereits abonniert... + feed_not_found: Wir konnten diesen Feed nicht finden. Probiere es noch einmal edit: fields: feed_name: Feed-Name @@ -40,6 +40,8 @@ de: index: add: hinzufügen add_some_feeds: Hey, du solltest ein paar Feeds %{add}. + destroy: + success: Feed gelöscht. first_run: password: anti_social: anti-sozial @@ -56,8 +58,7 @@ de: cookies_required: Das ist jetzt peinlich. Die App benötigt Cookies um richtig zu funktionieren. js_required: Das ist jetzt peinlich. Die App benötigt JavaScript um richtig zu funktionieren. import: - description: '%{link} deine Feeds aus Google Reader und importiere sie hier.' - export: Exportiere + description: Importieren Sie Ihre Feeds von einem anderen Dienst. fields: import: Importieren not_now: Nicht jetzt diff --git a/config/locales/el-GR.yml b/config/locales/el-GR.yml index 79f70e364..67b3491dc 100644 --- a/config/locales/el-GR.yml +++ b/config/locales/el-GR.yml @@ -25,11 +25,11 @@ el-GR: fields: feed_url: Διεύθυνση submit: Προσθήκη - flash: - added_successfully: Το καινούριο σας Ιστολόγιο προστέθηκε. Ελέγξτε παλι αργότερα. - already_subscribed_error: Είστε ήδη εγγεγραμενος σ' αυτο το ιστολόγιο... - feed_not_found_error: Δεν μπορέσαμε να βρούμε αυτο το ιστολόγιο. Προσπαθήστε ξανά. title: Όρεξη για καινούριες ειδήσεις? + create: + success: Το καινούριο σας Ιστολόγιο προστέθηκε. Ελέγξτε παλι αργότερα. + already_subscribed: Είστε ήδη εγγεγραμενος σ' αυτο το ιστολόγιο... + feed_not_found: Δεν μπορέσαμε να βρούμε αυτο το ιστολόγιο. Προσπαθήστε ξανά. edit: fields: feed_name: @@ -40,6 +40,8 @@ el-GR: index: add: εισάγεις add_some_feeds: Επ! Γιατί δεν %{add} μερικά ιστολόγια στη συλλογή σου? + destroy: + success: Η ροή διαγράφηκε. first_run: password: anti_social: αντι-κοινωνικό @@ -56,8 +58,7 @@ el-GR: cookies_required: Έχετε την καλοσύνη να ενεργοποιήσετε τα Cookies ώστε να λειτουργήσει κανονικά η εφαρμογή? js_required: Δεν είναι και τόσο περίεργο, αλλά η εφαρμογή στηρίζεται στην Javascript. import: - description: Πάρε τα ιστολόγια σου απο δω %{link} και εισήγαγε τα. - export: Εξαγωγή + description: Εισαγάγετε τις ροές σας από άλλη υπηρεσία. fields: import: Εισαγωγή not_now: Όχι τώρα diff --git a/config/locales/en.yml b/config/locales/en.yml index 8eb38af8d..0ad95644c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -25,11 +25,11 @@ en: fields: feed_url: Feed URL submit: Add - flash: - added_successfully: We've added your new feed. Check back in a bit. - already_subscribed_error: You are already subscribed to this feed... - feed_not_found_error: We couldn't find that feed. Try again. title: Need new stories? + create: + success: We've added your new feed. Check back in a bit. + already_subscribed: You are already subscribed to this feed... + feed_not_found: We couldn't find that feed. Try again. edit: fields: feed_name: Feed Name @@ -41,6 +41,8 @@ en: index: add: add add_some_feeds: Hey, you should %{add} some feeds. + destroy: + success: Feed deleted. first_run: password: anti_social: anti-social @@ -57,19 +59,20 @@ en: cookies_required: Well, this is awkward. Cookies are required for the app to work properly. js_required: Well, this is awkward. Javascript is required for the app to work properly. import: - description: '%{link} your feeds from Google Reader and import them.' - export: Export + description: Import your feeds from another service. fields: import: Import not_now: Not now subtitle: Let's setup your feeds. title: Welcome aboard. layout: + admin_settings: Admin Settings back_to_work: Get back to work, slacker! export: Export hey: Hey! import: Import logout: Logout + profile: Profile support: Support title: stringer | your rss buddy partials: @@ -114,20 +117,49 @@ en: archive: view all items go_make: go make something gtfo: Now stop reading blogs and - rss_zero: You've reached RSS Zero™ + rss_zero: You've reached RSS Zero™ + passwords: + update: + success: Password updated + failure: 'Unable to update password: %{errors}' + profiles: + edit: + title: Edit profile + warning_html: + Warning!!! Editing your username or password will + change your API key as well. You will need to update your credentials + in any Fever clients you have connected to Stringer. + update: + success: Profile updated + failure: 'Unable to update profile: %{errors}' sessions: destroy: flash: logged_out_successfully: You have been signed out! new: fields: + unknown_username_html: + Note: if you didn't set a username when you set up + your stringer instance, then your username will be "stringer". You + can change this on your profile page after logging in. + username: Username password: Password submit: Login flash: wrong_password: That's the wrong password. Try again. rss: RSS + sign_up_html: Or sign up. subtitle: Welcome back, friend. title: Stringer speaks + settings: + index: + heading: Application Settings + description: Edit application wide settings + signup: + enabled: User signups are enabled + disabled: User signups are disabled + enable: Enable + disable: Disable starred: next: Next of: of @@ -155,3 +187,10 @@ en: title: 'Stringer is ' your_feeds: your feeds your_stories: your stories + activerecord: + attributes: + user: + stories_order: "Stories feed order" + user/stories_order: + asc: "Oldest first" + desc: "Newest first" \ No newline at end of file diff --git a/config/locales/eo.yml b/config/locales/eo.yml index 4dd73b787..ce37826f9 100644 --- a/config/locales/eo.yml +++ b/config/locales/eo.yml @@ -25,11 +25,11 @@ eo: fields: feed_url: URL de fluo submit: Aldoni - flash: - added_successfully: Ni aldonis vian nova fluon. Revenu pli poste. - already_subscribed_error: Vi abonis tiun fluon jam... - feed_not_found_error: Ni ne eblas trovi tion fluon. Reprovu. title: Ĉu vi bezonas novajn rakontojn? + create: + success: Ni aldonis vian nova fluon. Revenu pli poste. + already_subscribed: Vi abonis tiun fluon jam... + feed_not_found: Ni ne eblas trovi tion fluon. Reprovu. edit: fields: feed_name: Nomo de fluo @@ -40,6 +40,8 @@ eo: index: add: aldoni add_some_feeds: He, vi devas %{add} kelkajn fluojn. + destroy: + success: Fluo forigita. first_run: password: anti_social: kontraŭsocia @@ -56,8 +58,7 @@ eo: cookies_required: Nu, mallerteco. Kuketoj estas postulita por funkcii dece. js_required: Nu, mallerteco. JavaScript estas postulita por funkcii dece. import: - description: '%{link} viajn fluojn de Google Reader kaj importi ilin.' - export: Eksporti + description: Importu viajn fluojn de alia servo. fields: import: Importi not_now: Ne nun diff --git a/config/locales/es.yml b/config/locales/es.yml index 6f7a21446..6d9371ba8 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -25,11 +25,11 @@ es: fields: feed_url: URL de la feed submit: Añadir - flash: - added_successfully: Hemos agregado tu nueva feed. Regresa en un ratito. - already_subscribed_error: Ya te suscribiste a esta feed... - feed_not_found_error: No pudimos encontrar esa feed. Inténtalo de vuelta. title: ¿Necesitas nuevas historias? + create: + success: Hemos agregado tu nueva feed. Regresa en un ratito. + already_subscribed: Ya te suscribiste a esta feed... + feed_not_found: No pudimos encontrar esa feed. Inténtalo de vuelta. edit: fields: feed_name: Nombre fuente @@ -40,6 +40,8 @@ es: index: add: agregar add_some_feeds: Oye, deberias %{add} algunas feeds. + destroy: + success: Feed eliminado. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ es: cookies_required: Bueno esto es incomodo. Cookies son requeridas para que la aplicación funcione correctamente. js_required: Bueno esto es incomodo. JavaScript es requerido para que la aplicación funcione correctamente. import: - description: '%{link} tus feeds desde Google Reader e importalas.' - export: Exportar + description: Importa tus feeds desde otro servicio. fields: import: Importar not_now: No ahora diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 172e6328a..b20767fef 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -25,11 +25,11 @@ fr: fields: feed_url: URL du flux submit: Ajouter - flash: - added_successfully: Le nouveau flux a été ajouté. Patientez quelques instants. - already_subscribed_error: Vous suivez déjà ce flux... - feed_not_found_error: Nous n'avons pas pu trouver ce flux. Essayez de nouveau. title: Besoin de nouveaux articles ? + create: + success: Le nouveau flux a été ajouté. Patientez quelques instants. + already_subscribed: Vous suivez déjà ce flux... + feed_not_found: Nous n'avons pas pu trouver ce flux. Essayez de nouveau. edit: fields: feed_name: @@ -40,6 +40,8 @@ fr: index: add: ajouter add_some_feeds: Vous devriez %{add} quelques flux. + destroy: + success: Flux supprimé. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ fr: cookies_required: 'C''est embêtant : les cookies sont obligatoires pour le bon fonctionnement de l''application.' js_required: 'C''est embêtant : Javascript est obligatoire pour le bon fonctionnement de l''application.' import: - description: '%{link} vos flux provenant de Google Reader et importez-les.' - export: Exportez + description: Importez vos flux depuis un autre service. fields: import: Importez not_now: Pas maintenant diff --git a/config/locales/he.yml b/config/locales/he.yml index 9e6d2c96d..35462b88f 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -25,11 +25,11 @@ he: fields: feed_url: כתובת של הפיד submit: להוסיף - flash: - added_successfully: אנחנו הוספנו את הפיד החדש. עוד מעט תקבל עידכונים. - already_subscribed_error: הפיד הנ"ל כבר נמצא במעקב. - feed_not_found_error: לא הצלחנו למצוא את הפיד. נסה שוב. title: מחפש מה לקרוא? + create: + success: אנחנו הוספנו את הפיד החדש. עוד מעט תקבל עידכונים. + already_subscribed: הפיד הנ"ל כבר נמצא במעקב. + feed_not_found: לא הצלחנו למצוא את הפיד. נסה שוב. edit: fields: feed_name: @@ -40,6 +40,8 @@ he: index: add: להוסיף add_some_feeds: הי, כדאי %{add} קצת פידים. + destroy: + success: הפיד נמחק. first_run: password: anti_social: אינו חברתי @@ -56,7 +58,7 @@ he: cookies_required: js_required: import: - description: '%{link} את הפיד שלך מ- Google Reader ויבא אותו.' + description: ייבא את העדכונים שלך משירות אחר. export: יצוא fields: import: יבוא diff --git a/config/locales/it.yml b/config/locales/it.yml index d0b4cbaea..550e47bbe 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -25,11 +25,11 @@ it: fields: feed_url: URL del feed submit: Aggiungi - flash: - added_successfully: Abbiamo aggiunto il tuo nuovo feed. Ripassa tra qualche istante. - already_subscribed_error: Sei già sottoscritto a questo feed... - feed_not_found_error: Non siamo riusciti a trovare il feed. Riprova. title: Bisogno di nuove storie? + create: + success: Abbiamo aggiunto il tuo nuovo feed. Ripassa tra qualche istante. + already_subscribed: Sei già sottoscritto a questo feed... + feed_not_found: Non siamo riusciti a trovare il feed. Riprova. edit: fields: feed_name: @@ -40,6 +40,8 @@ it: index: add: aggiungere add_some_feeds: Hey, dovresti %{add} qualche feed. + destroy: + success: Feed eliminato. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ it: cookies_required: Beh, questo sì che è strano. L'app necessita di cookie per funzionare correttamente. js_required: Beh, questo sì che è strano. L'app necessita di Javascript attivato per funzionare correttamente. import: - description: '%{link} i tuoi feed da Google Reader e importali qui.' - export: Esporta + description: Importa i tuoi feed da un altro servizio. fields: import: Importa not_now: Non adesso diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 4f30eb756..0e227cf2e 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -25,11 +25,11 @@ ja: fields: feed_url: フィードURL submit: 追加 - flash: - added_successfully: 新しいフィードを追加しました、少し経ってから確認して下さい - already_subscribed_error: このフィードは既に登録されてます - feed_not_found_error: フィードを見つけられませんでした、もう一度試して下さい title: 新しいストーリーが必要ですか? + create: + success: 新しいフィードを追加しました、少し経ってから確認して下さい + already_subscribed: このフィードは既に登録されてます + feed_not_found: フィードを見つけられませんでした、もう一度試して下さい edit: fields: feed_name: フィード名 @@ -40,6 +40,8 @@ ja: index: add: 追加 add_some_feeds: 何かフィードを%{add}する必要があります + destroy: + success: フィードを削除しました。 first_run: password: anti_social: アンチソーシャル @@ -56,8 +58,7 @@ ja: cookies_required: 正常に動作するためにクッキーを有効にして下さい js_required: 正常に動作するためにJavascriptを有効にして下さい import: - description: '%{link} あなたのGoogle Readerからのフィードです、それを取り込めます' - export: 書き出し + description: 別のサービスからフィードをインポートします。 fields: import: 取り込み not_now: いまはやらない diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 115a908e4..ceb853806 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -25,11 +25,11 @@ nl: fields: feed_url: Feed-URL submit: Toevoegen - flash: - added_successfully: We hebben je nieuwe feed toegevoegd. Kijk over een tijdje nog eens. - already_subscribed_error: Je bent al geabonneerd op deze feed... - feed_not_found_error: Die feed konden we niet vinden. Probeer het opnieuw. title: Nieuwe artikelen nodig? + create: + success: We hebben je nieuwe feed toegevoegd. Kijk over een tijdje nog eens. + already_subscribed: Je bent al geabonneerd op deze feed... + feed_not_found: Die feed konden we niet vinden. Probeer het opnieuw. edit: fields: feed_name: Feednaam @@ -40,6 +40,8 @@ nl: index: add: toevoegen add_some_feeds: Hé, je zou eens wat feeds kunnen %{add}. + destroy: + success: Voer verwijderd. first_run: password: anti_social: anti-sociaal @@ -56,8 +58,7 @@ nl: cookies_required: 'Wel, dit is vervelend: deze applicatie heeft cookies nodig om goed te werken.' js_required: 'Wel, dit is vervelend: deze applicatie heeft Javascript nodig om goed te werken.' import: - description: '%{link} je feeds uit Google Reader en importeer ze.' - export: Exporteer + description: Importeer uw feeds van een andere service. fields: import: Importeren not_now: Niet nu diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 0a18bfe00..5b04cbdc0 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -25,11 +25,11 @@ pt-BR: fields: feed_url: URL do Feed submit: Adicionar - flash: - added_successfully: Nós estamos adicionando um novo feed. Volte daqui a pouco. - already_subscribed_error: Você já está inscrito neste feed... - feed_not_found_error: Não conseguimos achar este feed. Tente novamente. title: Precisa de novas histórias? + create: + success: Nós estamos adicionando um novo feed. Volte daqui a pouco. + already_subscribed: Você já está inscrito neste feed... + feed_not_found: Não conseguimos achar este feed. Tente novamente. edit: fields: feed_name: @@ -40,6 +40,8 @@ pt-BR: index: add: adicionar add_some_feeds: Ei, você deveria %{add} alguns feeds. + destroy: + success: Feed deletado. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ pt-BR: cookies_required: Bem, isso é estranho. É necessário habilitar Cookies para que o aplicativo funcione corretamente. js_required: Bem, isso é estranho. É necessário habilitar Javascript para que o aplicativo funcione corretamente. import: - description: '%{link} seus feeds do Google Reader e importe-os.' - export: Exportar + description: Importe seus feeds de outro serviço. fields: import: Importar not_now: Agora não diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 054b19bb8..ae3ad0544 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -25,11 +25,11 @@ pt: fields: feed_url: URL da Feed submit: Adicionar - flash: - added_successfully: Adicionamos a sua nova feed. Verifique novamente mais tarde. - already_subscribed_error: Você já subscreveu esta feed... - feed_not_found_error: Não foi possível encontrar a feed. Tente novamente. title: Precisa de novas histórias? + create: + success: Adicionamos a sua nova feed. Verifique novamente mais tarde. + already_subscribed: Você já subscreveu esta feed... + feed_not_found: Não foi possível encontrar a feed. Tente novamente. edit: fields: feed_name: @@ -40,6 +40,8 @@ pt: index: add: adicionar add_some_feeds: Ei, você deveria %{add} algumas feeds. + destroy: + success: first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ pt: cookies_required: js_required: import: - description: '%{link} as suas feeds do Google Reader e importe-as.' - export: Exportar + description: Importe seus feeds de outro serviço. fields: import: Importar not_now: Agora não diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 30299b1ab..fb3ad5dbf 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -25,11 +25,11 @@ ru: fields: feed_url: URL фида submit: Добавить - flash: - added_successfully: Мы добавили новый фид. Скоро информация обновится. - already_subscribed_error: Вы уже подписаны на этот фид... - feed_not_found_error: Мы не смогли найти этот фид. Попробуйте еще раз. title: Нужны новые истории? + create: + success: Мы добавили новый фид. Скоро информация обновится. + already_subscribed: Вы уже подписаны на этот фид... + feed_not_found: Мы не смогли найти этот фид. Попробуйте еще раз. edit: fields: feed_name: @@ -39,7 +39,9 @@ ru: updated_successfully: index: add: добавить - add_some_feeds: Эй, ​​Вы должны %{add} некоторые фид каналы. + add_some_feeds: Эй, Вы должны %{add} некоторые фид каналы. + destroy: + success: Фид удален. first_run: password: anti_social: антиобщественное @@ -56,8 +58,7 @@ ru: cookies_required: Ну, это неудобно. Cookies необходимы, чтобы приложение работало корректно. js_required: Ну, это неудобно. Javascript необходим, чтобы приложение работало корректно. import: - description: '%{link} фиды из Google Reader и импортировать их.' - export: Экспортировать + description: Импортируйте свои каналы из другого сервиса. fields: import: Импортировать not_now: Не сейчас diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 12487e118..d24adef24 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -25,11 +25,11 @@ sv: fields: feed_url: Feedens URL submit: Lägg till - flash: - added_successfully: Vi har lagt till din nya feed. Kom tillbaks om en stund. - already_subscribed_error: Du prenumererar redan på den här feeden... - feed_not_found_error: Vi kunde inte hitta feeden. Prova igen. title: Behöver du nya berättelser? + create: + success: Vi har lagt till din nya feed. Kom tillbaks om en stund. + already_subscribed: Du prenumererar redan på den här feeden... + feed_not_found: Vi kunde inte hitta feeden. Prova igen. edit: fields: feed_name: Feednamn @@ -40,6 +40,8 @@ sv: index: add: lägga till add_some_feeds: Hej, du borde %{add} några feeds. + destroy: + success: Flödet raderat. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ sv: cookies_required: Jaha, det här var tråkigt. Cookies behövs för att appen ska fungera. js_required: Jaha, det här var tråkigt. Javascript behövs för att appen ska fungera. import: - description: '%{link} dina feeds från Google Reader och importera dem.' - export: Exportera + description: Importera dina flöden från en annan tjänst. fields: import: Importera not_now: Inte nu diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 26523682f..7b5ec359d 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -25,11 +25,11 @@ tr: fields: feed_url: Besleme URL'si submit: Ekle - flash: - added_successfully: Yeni besleme eklenmistir. Sonra kontrol edin. - already_subscribed_error: Bu beslemeye zaten kayitlisiniz... - feed_not_found_error: Bu beslemeyi bulamadik. Tekrar deneyiniz. title: Yeni hikayelere mi ihtiyaciniz var? + create: + success: Yeni besleme eklenmistir. Sonra kontrol edin. + already_subscribed: Bu beslemeye zaten kayitlisiniz... + feed_not_found: Bu beslemeyi bulamadik. Tekrar deneyiniz. edit: fields: feed_name: @@ -40,6 +40,8 @@ tr: index: add: ekle add_some_feeds: Hey, you should %{add} some feeds. + destroy: + success: Besleme silindi. first_run: password: anti_social: anti-social @@ -56,8 +58,7 @@ tr: cookies_required: Aaa, garip!. Uygulamanin duzgun calismasi icin Cookies gerekli. js_required: Aaa, garip. Uygulamanin duzgun calismasi icin Javascript gerekli. import: - description: - export: Disa Aktar + description: Beslemelerinizi başka bir hizmetten içe aktarın. fields: import: Ekle not_now: Simdi Degil diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 275dee1ad..3dda78636 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -25,11 +25,11 @@ zh-CN: fields: feed_url: 供稿地址 submit: 添加 - flash: - added_successfully: 你的订阅已经添加完毕,稍等一段时间就可以阅读啦 - already_subscribed_error: 你已经订阅过这个供稿了哟... - feed_not_found_error: 呃,我们无法识别这个供稿地址。麻烦你检查后再试一次。 title: 想要添加新内容? + create: + success: 你的订阅已经添加完毕,稍等一段时间就可以阅读啦 + already_subscribed: 你已经订阅过这个供稿了哟... + feed_not_found: 呃,我们无法识别这个供稿地址。麻烦你检查后再试一次。 edit: fields: feed_name: @@ -40,6 +40,8 @@ zh-CN: index: add: 添加 add_some_feeds: 你应该%{add}一些订阅哟~ + destroy: + success: 提要已删除。 first_run: password: anti_social: 反社交化的 @@ -56,8 +58,7 @@ zh-CN: cookies_required: 抱歉,你的浏览器禁用了Cookie记录功能。为了你的正常使用,请开启浏览器的Cookie功能。 js_required: 抱歉,你的浏览器禁用了Javascript脚本。为了你的正常使用,请开启脚本功能。 import: - description: '%{link}你在Google Reader上的订阅列表然后再将它们导入这里' - export: 导出 + description: 从其他服务导入您的提要。 fields: import: 导入 not_now: 稍后再说 diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml new file mode 100644 index 000000000..080fef5ce --- /dev/null +++ b/config/locales/zh-TW.yml @@ -0,0 +1,195 @@ +zh-TW: + archive: + next: 下一頁 + of: 之 + previous: 上一頁 + sorry: 您目前還沒有任何已讀的文章! + date: + abbr_month_names: + - + - 1 月 + - 2 月 + - 3 月 + - 4 月 + - 5 月 + - 6 月 + - 7 月 + - 8 月 + - 9 月 + - 10 月 + - 11 月 + - 12 月 + feeds: + add: + description: 請輸入您要訂閱的 RSS 來源網址。 + fields: + feed_url: RSS 網址 + submit: 新增 + title: 想要新增新內容嗎? + create: + success: 您的訂閱已經新增完畢,請稍候一段時間再回來確認。 + already_subscribed: 您已經訂閱過這個 RSS 了... + feed_not_found: 我們找不到這個 RSS,請檢查後再試一次。 + edit: + fields: + feed_name: RSS 名稱 + feed_url: RSS 網址 + group: 群組 + submit: 儲存 + flash: + updated_successfully: 已為您更新 RSS 訂閱! + index: + add: 新增 + add_some_feeds: 嘿!您應該要%{add}一些 RSS 訂閱。 + destroy: + success: RSS 已刪除。 + first_run: + password: + anti_social: 反社交化的 + description: 讓我們設定一組您的密碼,然後您就可以開始享受獨自閱讀的樂趣了。 + fields: + next: 下一步 + password: 密碼 + password_confirmation: 確認密碼 + flash: + passwords_dont_match: 您兩次輸入的密碼不同,麻煩確認並重新輸入。 + subtitle: 這裡只有一位使用者,那就是您。 + title: Stringer 是 + flash: + cookies_required: 抱歉,您的瀏覽器似乎停用了 Cookie,請啟用瀏覽器的 Cookie 功能才能正常使用。 + js_required: 抱歉,您的瀏覽器似乎停用了 JavaScript,請啟用瀏覽器的 JavaScript 功能才能正常使用。 + import: + description: 從其他服務匯入您的 RSS 訂閱。 + fields: + import: 匯入 + not_now: 稍後再說 + subtitle: 讓我們開始設定您的 RSS 訂閱吧。 + title: 歡迎使用。 + layout: + admin_settings: 管理員設定 + back_to_work: 該回去工作了,偷懶鬼! + export: 匯出 + hey: 嘿! + import: 匯入 + logout: 登出 + profile: 個人資料 + support: 支援 + title: stringer | 您的 RSS 好夥伴 + partials: + action_bar: + add_feed: 新增訂閱 + archived_stories: 已封存的文章 + mark_all: 全部標示為已讀 + refresh: 重新整理 + starred_stories: 星號標記的文章 + view_feeds: 檢視訂閱列表 + feed: + last_fetched: + never: 尚未成功讀取過 + last_updated: 最後更新於 + status_bubble: + green: 成功! + red: 解析時出現錯誤(而且從來沒成功過!此訂閱可能已經失效!) + yellow: 解析時出現錯誤(可能只是暫時的) + feed_action_bar: + add_feed: 新增訂閱 + archived_stories: 已封存的文章 + feeds: 檢視訂閱列表 + home: 返回文章列表 + starred_stories: 星號標記的文章 + shortcuts: + keys: + a: 新增訂閱 + bv: 打開文章網址 + f: 打開 RSS 頁面 + jk: 下一篇/上一篇文章 + left: 上一頁 + m: 標示文章為已讀/未讀 + np: 向下/向上移動 + oenter: 點選打開/關閉 + or: 或 + r: 重新整理 + right: 下一頁 + s: 為文章加上/移除星號 + shifta: 全部標示為已讀 + title: 鍵盤快速鍵 + zen: + archive: 查看所有文章 + go_make: 去做些什麼吧 + gtfo: 現在停止閱讀然後 + rss_zero: 您已達成清空 RSS 的任務 (RSS Zero™) + passwords: + update: + success: 密碼已更新 + failure: '無法更新密碼:%{errors}' + profiles: + edit: + title: 編輯個人資料 + warning_html: + 警告!編輯您的使用者名稱或密碼將同時 + 變更您的 API 金鑰。您需要更新任何已連接至 Stringer 的 + Fever 客戶端中的認證資訊。 + update: + success: 個人資料已更新 + failure: '無法更新個人資料:%{errors}' + sessions: + destroy: + flash: + logged_out_successfully: 您已成功登出! + new: + fields: + unknown_username_html: + 注意:如果您在設定 Stringer 時沒有設定使用者名稱, + 則您的使用者名稱將會是「stringer」。登入後您可以在個人資料頁面中修改。 + username: 使用者名稱 + password: 密碼 + submit: 登入 + flash: + wrong_password: 您輸入的密碼錯誤,請重新輸入。 + rss: RSS + sign_up_html: 或註冊。 + subtitle: 歡迎回來,我的朋友。 + title: Stringer 說 + settings: + index: + heading: 應用程式設定 + description: 編輯應用程式通用設定 + signup: + enabled: 使用者註冊已啟用 + disabled: 使用者註冊已停用 + enable: 啟用 + disable: 停用 + starred: + next: 下一頁 + of: 之 + previous: 上一頁 + sorry: 抱歉,您還沒有為任何文章加上星號! + stories: + keep_unread: 保持未讀 + time: + formats: + default: '%b %d 日 %H:%M' + tutorial: + add_feed: 新增訂閱 + as_read: 為已讀 + click_to_read: (點選閱讀) + description: 我們正在為您取得訂閱的內容,請稍候。 + heroku_hourly_task: 您需要新增一個每小時執行的工作來檢查新文章。 + heroku_one_more_thing: 還有一件事... + heroku_scheduler: 前往 Heroku Scheduler 並新增這個工作 + mark_all: 全部標示 + ready: 好了,準備就緒! + refresh: 重新整理 + simple: 簡單的 + start: 開始閱讀 + subtitle: 這是一份簡單的使用手冊。 + title: 'Stringer 是 ' + your_feeds: 您的 RSS + your_stories: 您的文章 + activerecord: + attributes: + user: + stories_order: "文章排序方式" + user/stories_order: + asc: "最舊優先" + desc: "最新優先" \ No newline at end of file diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..294f29076 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is +# 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 000000000..3016a3e66 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "../lib/admin_constraint" + +Rails.application.routes.draw do + scope :admin, constraints: AdminConstraint.new do + mount GoodJob::Engine => "good_job" + + resources :settings, only: [:index, :update] + get "/debug", to: "debug#index" + end + + resource :profile, only: [:edit, :update] + resource :password, only: [:update] + + get "/", to: "stories#index" + get "/fever", to: "fever#index" + post "/fever", to: "fever#update" + get "/archive", to: "stories#archived" + get "/feed/:feed_id", to: "feeds#show" + post "/feeds", to: "feeds#create" + get "/feeds", to: "feeds#index" + delete "/feeds/:id", to: "feeds#destroy" + put "/feeds/:id", to: "feeds#update" + get "/feeds/:id/edit", to: "feeds#edit" + get "/feeds/export", to: "exports#index" + post "/feeds/import", to: "imports#create" + get "/feeds/import", to: "imports#new" + get "/feeds/new", to: "feeds#new" + get "/heroku", to: "debug#heroku" + post "/login", to: "sessions#create" + get "/login", to: "sessions#new" + get "/logout", to: "sessions#destroy" + get "/news", to: "stories#index" + post "/setup/password", to: "passwords#create" + get "/setup/password", to: "passwords#new" + get "/setup/tutorial", to: "tutorials#index" + get "/starred", to: "stories#starred" + put "/stories/:id", to: "stories#update" + post "/stories/mark_all_as_read", to: "stories#mark_all_as_read" + + unless Rails.env.production? + require_relative "../spec/javascript/test_controller" + + get "/test", to: "test#index" + get "/spec/*splat", to: "test#spec" + get "/vendor/js/*splat", to: "test#vendor" + get "/vendor/css/*splat", to: "test#vendor" + end +end diff --git a/config/unicorn.rb b/config/unicorn.rb deleted file mode 100644 index 0171aba70..000000000 --- a/config/unicorn.rb +++ /dev/null @@ -1,23 +0,0 @@ -worker_processes 1 -timeout 30 -preload_app true - -@delayed_job_pid = nil - -before_fork do |_server, _worker| - # the following is highly recommended for Rails + "preload_app true" - # as there's no need for the master process to hold a connection - ActiveRecord::Base.connection.disconnect! if defined?(ActiveRecord::Base) - - @delayed_job_pid ||= spawn("bundle exec rake work_jobs") unless ENV["WORKER_EMBEDDED"] == "false" - - sleep 1 -end - -after_fork do |_server, _worker| - if defined?(ActiveRecord::Base) - env = ENV["RACK_ENV"] || "development" - config = YAML.load(ERB.new(File.read("config/database.yml")).result)[env] - ActiveRecord::Base.establish_connection(config) - end -end diff --git a/db/migrate/20130409010818_create_feeds.rb b/db/migrate/20130409010818_create_feeds.rb index c1eec66df..26b2ca2e0 100644 --- a/db/migrate/20130409010818_create_feeds.rb +++ b/db/migrate/20130409010818_create_feeds.rb @@ -1,11 +1,13 @@ -class CreateFeeds < ActiveRecord::Migration +# frozen_string_literal: true + +class CreateFeeds < ActiveRecord::Migration[4.2] def change create_table :feeds do |t| t.string :name t.string :url t.timestamp :last_fetched - t.timestamps + t.timestamps null: false end end end diff --git a/db/migrate/20130409010826_create_stories.rb b/db/migrate/20130409010826_create_stories.rb index 0d9bcc91e..73b494077 100644 --- a/db/migrate/20130409010826_create_stories.rb +++ b/db/migrate/20130409010826_create_stories.rb @@ -1,4 +1,6 @@ -class CreateStories < ActiveRecord::Migration +# frozen_string_literal: true + +class CreateStories < ActiveRecord::Migration[4.2] def change create_table :stories do |t| t.string :title @@ -7,7 +9,7 @@ def change t.references :feed - t.timestamps + t.timestamps null: false end end end diff --git a/db/migrate/20130412185253_add_new_fields_to_stories.rb b/db/migrate/20130412185253_add_new_fields_to_stories.rb index 58916f7f6..e3c95eda9 100644 --- a/db/migrate/20130412185253_add_new_fields_to_stories.rb +++ b/db/migrate/20130412185253_add_new_fields_to_stories.rb @@ -1,4 +1,6 @@ -class AddNewFieldsToStories < ActiveRecord::Migration +# frozen_string_literal: true + +class AddNewFieldsToStories < ActiveRecord::Migration[4.2] def change add_column :stories, :published, :timestamp add_column :stories, :is_read, :boolean diff --git a/db/migrate/20130418221144_add_user_model.rb b/db/migrate/20130418221144_add_user_model.rb index f454fc12e..6c8ccb858 100644 --- a/db/migrate/20130418221144_add_user_model.rb +++ b/db/migrate/20130418221144_add_user_model.rb @@ -1,10 +1,12 @@ -class AddUserModel < ActiveRecord::Migration +# frozen_string_literal: true + +class AddUserModel < ActiveRecord::Migration[4.2] def change create_table :users do |t| t.string :email t.string :password_digest - t.timestamps + t.timestamps null: false end end end diff --git a/db/migrate/20130423001740_drop_email_from_user.rb b/db/migrate/20130423001740_drop_email_from_user.rb index e07e5eda1..9579bdfa7 100644 --- a/db/migrate/20130423001740_drop_email_from_user.rb +++ b/db/migrate/20130423001740_drop_email_from_user.rb @@ -1,4 +1,6 @@ -class DropEmailFromUser < ActiveRecord::Migration +# frozen_string_literal: true + +class DropEmailFromUser < ActiveRecord::Migration[4.2] def up remove_column :users, :email end diff --git a/db/migrate/20130423180446_remove_author_from_stories.rb b/db/migrate/20130423180446_remove_author_from_stories.rb index b38517f6d..37061a611 100644 --- a/db/migrate/20130423180446_remove_author_from_stories.rb +++ b/db/migrate/20130423180446_remove_author_from_stories.rb @@ -1,4 +1,6 @@ -class RemoveAuthorFromStories < ActiveRecord::Migration +# frozen_string_literal: true + +class RemoveAuthorFromStories < ActiveRecord::Migration[4.2] def up remove_column :stories, :author end diff --git a/db/migrate/20130425211008_add_setup_complete_to_user.rb b/db/migrate/20130425211008_add_setup_complete_to_user.rb index 1e5a57f1c..39f29aacb 100644 --- a/db/migrate/20130425211008_add_setup_complete_to_user.rb +++ b/db/migrate/20130425211008_add_setup_complete_to_user.rb @@ -1,4 +1,6 @@ -class AddSetupCompleteToUser < ActiveRecord::Migration +# frozen_string_literal: true + +class AddSetupCompleteToUser < ActiveRecord::Migration[4.2] def change add_column :users, :setup_complete, :boolean end diff --git a/db/migrate/20130425222157_add_delayed_job.rb b/db/migrate/20130425222157_add_delayed_job.rb index 6c32be2ca..d4afefab8 100644 --- a/db/migrate/20130425222157_add_delayed_job.rb +++ b/db/migrate/20130425222157_add_delayed_job.rb @@ -1,4 +1,6 @@ -class AddDelayedJob < ActiveRecord::Migration +# frozen_string_literal: true + +class AddDelayedJob < ActiveRecord::Migration[4.2] def self.up create_table :delayed_jobs, force: true do |table| # Allows some jobs to jump to the front of the queue @@ -30,7 +32,7 @@ def self.up # The name of the queue this job is in table.string :queue - table.timestamps + table.timestamps null: false end add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" diff --git a/db/migrate/20130429232127_add_status_to_feeds.rb b/db/migrate/20130429232127_add_status_to_feeds.rb index da45f32bf..a458e8528 100644 --- a/db/migrate/20130429232127_add_status_to_feeds.rb +++ b/db/migrate/20130429232127_add_status_to_feeds.rb @@ -1,4 +1,6 @@ -class AddStatusToFeeds < ActiveRecord::Migration +# frozen_string_literal: true + +class AddStatusToFeeds < ActiveRecord::Migration[4.2] def change add_column :feeds, :status, :int end diff --git a/db/migrate/20130504005816_text_url.rb b/db/migrate/20130504005816_text_url.rb index a62d7a10d..57e02d5cb 100644 --- a/db/migrate/20130504005816_text_url.rb +++ b/db/migrate/20130504005816_text_url.rb @@ -1,4 +1,6 @@ -class TextUrl < ActiveRecord::Migration +# frozen_string_literal: true + +class TextUrl < ActiveRecord::Migration[4.2] def up change_column :feeds, :url, :text end diff --git a/db/migrate/20130504022615_change_story_permalink_column.rb b/db/migrate/20130504022615_change_story_permalink_column.rb index 69297bc9e..503a2dbd9 100644 --- a/db/migrate/20130504022615_change_story_permalink_column.rb +++ b/db/migrate/20130504022615_change_story_permalink_column.rb @@ -1,4 +1,6 @@ -class ChangeStoryPermalinkColumn < ActiveRecord::Migration +# frozen_string_literal: true + +class ChangeStoryPermalinkColumn < ActiveRecord::Migration[4.2] def up change_column :stories, :permalink, :text end diff --git a/db/migrate/20130509131045_add_unique_constraints.rb b/db/migrate/20130509131045_add_unique_constraints.rb index fad3d9f2a..270dde2ba 100644 --- a/db/migrate/20130509131045_add_unique_constraints.rb +++ b/db/migrate/20130509131045_add_unique_constraints.rb @@ -1,4 +1,6 @@ -class AddUniqueConstraints < ActiveRecord::Migration +# frozen_string_literal: true + +class AddUniqueConstraints < ActiveRecord::Migration[4.2] def change add_index :stories, [:permalink, :feed_id], unique: true add_index :feeds, :url, unique: true diff --git a/db/migrate/20130513025939_add_keep_unread_to_stories.rb b/db/migrate/20130513025939_add_keep_unread_to_stories.rb index b3311f32c..98b42cdf1 100644 --- a/db/migrate/20130513025939_add_keep_unread_to_stories.rb +++ b/db/migrate/20130513025939_add_keep_unread_to_stories.rb @@ -1,4 +1,6 @@ -class AddKeepUnreadToStories < ActiveRecord::Migration +# frozen_string_literal: true + +class AddKeepUnreadToStories < ActiveRecord::Migration[4.2] def change add_column :stories, :keep_unread, :boolean, default: false end diff --git a/db/migrate/20130513044029_add_is_starred_status_for_stories.rb b/db/migrate/20130513044029_add_is_starred_status_for_stories.rb index 63423ab31..4d64c1b19 100644 --- a/db/migrate/20130513044029_add_is_starred_status_for_stories.rb +++ b/db/migrate/20130513044029_add_is_starred_status_for_stories.rb @@ -1,4 +1,6 @@ -class AddIsStarredStatusForStories < ActiveRecord::Migration +# frozen_string_literal: true + +class AddIsStarredStatusForStories < ActiveRecord::Migration[4.2] def change add_column :stories, :is_starred, :boolean, default: false end diff --git a/db/migrate/20130522014405_add_api_key_to_user.rb b/db/migrate/20130522014405_add_api_key_to_user.rb index b76d63c02..d8942cfdc 100644 --- a/db/migrate/20130522014405_add_api_key_to_user.rb +++ b/db/migrate/20130522014405_add_api_key_to_user.rb @@ -1,4 +1,6 @@ -class AddApiKeyToUser < ActiveRecord::Migration +# frozen_string_literal: true + +class AddAPIKeyToUser < ActiveRecord::Migration[4.2] def change add_column :users, :api_key, :string end diff --git a/db/migrate/20130730120312_add_entry_id_to_stories.rb b/db/migrate/20130730120312_add_entry_id_to_stories.rb index 447e06ca1..fe25c77ed 100644 --- a/db/migrate/20130730120312_add_entry_id_to_stories.rb +++ b/db/migrate/20130730120312_add_entry_id_to_stories.rb @@ -1,4 +1,6 @@ -class AddEntryIdToStories < ActiveRecord::Migration +# frozen_string_literal: true + +class AddEntryIdToStories < ActiveRecord::Migration[4.2] def change add_column :stories, :entry_id, :string end diff --git a/db/migrate/20130805113712_update_stories_unique_constraints.rb b/db/migrate/20130805113712_update_stories_unique_constraints.rb index 0cb354727..326049b16 100644 --- a/db/migrate/20130805113712_update_stories_unique_constraints.rb +++ b/db/migrate/20130805113712_update_stories_unique_constraints.rb @@ -1,11 +1,19 @@ -class UpdateStoriesUniqueConstraints < ActiveRecord::Migration +# frozen_string_literal: true + +class UpdateStoriesUniqueConstraints < ActiveRecord::Migration[4.2] def up remove_index :stories, [:permalink, :feed_id] - add_index :stories, [:entry_id, :feed_id], unique: true, length: { permalink: 767 } + add_index :stories, + [:entry_id, :feed_id], + unique: true, + length: { permalink: 767 } end def down remove_index :stories, [:entry_id, :feed_id] - add_index :stories, [:permalink, :feed_id], unique: true, length: { permalink: 767 } + add_index :stories, + [:permalink, :feed_id], + unique: true, + length: { permalink: 767 } end end diff --git a/db/migrate/20130821020313_update_nil_entry_ids.rb b/db/migrate/20130821020313_update_nil_entry_ids.rb index 57739f60d..62773f217 100644 --- a/db/migrate/20130821020313_update_nil_entry_ids.rb +++ b/db/migrate/20130821020313_update_nil_entry_ids.rb @@ -1,12 +1,14 @@ -class UpdateNilEntryIds < ActiveRecord::Migration +# frozen_string_literal: true + +class UpdateNilEntryIds < ActiveRecord::Migration[4.2] def up - Story.where(entry_id: nil).each do |story| + Story.where(entry_id: nil).find_each do |story| story.entry_id = story.permalink || story.id story.save end end - def self.down + def down # skip end end diff --git a/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb b/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb index a08210f18..1d065d2b1 100644 --- a/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb +++ b/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb @@ -1,10 +1,12 @@ -class UseTextDatatypeForTitleAndEntryId < ActiveRecord::Migration +# frozen_string_literal: true + +class UseTextDatatypeForTitleAndEntryId < ActiveRecord::Migration[4.2] def up change_column :stories, :title, :text change_column :stories, :entry_id, :text end - def self.down + def down change_column :stories, :title, :string change_column :stories, :entry_id, :string end diff --git a/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb b/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb index 1ff359975..d862f5972 100644 --- a/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb +++ b/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb @@ -1,4 +1,6 @@ -class AddGroupsTableAndForeignKeysToFeeds < ActiveRecord::Migration +# frozen_string_literal: true + +class AddGroupsTableAndForeignKeysToFeeds < ActiveRecord::Migration[4.2] def up create_table :groups do |t| t.string :name, null: false diff --git a/db/migrate/20140421224454_fix_invalid_unicode.rb b/db/migrate/20140421224454_fix_invalid_unicode.rb index d9200cd6c..2f556a154 100644 --- a/db/migrate/20140421224454_fix_invalid_unicode.rb +++ b/db/migrate/20140421224454_fix_invalid_unicode.rb @@ -1,4 +1,6 @@ -class FixInvalidUnicode < ActiveRecord::Migration +# frozen_string_literal: true + +class FixInvalidUnicode < ActiveRecord::Migration[4.2] def up Story.find_each do |story| valid_body = story.body.delete("\u2028").delete("\u2029") diff --git a/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb b/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb index 4202b0bc8..bea9b1a2f 100644 --- a/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb +++ b/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb @@ -1,4 +1,6 @@ -class FixInvalidTitlesWithUnicodeLineEndings < ActiveRecord::Migration +# frozen_string_literal: true + +class FixInvalidTitlesWithUnicodeLineEndings < ActiveRecord::Migration[4.2] def up Story.find_each do |story| unless story.title.nil? diff --git a/db/migrate/20221206231914_add_enclosure_url_to_stories.rb b/db/migrate/20221206231914_add_enclosure_url_to_stories.rb new file mode 100644 index 000000000..1c20b317d --- /dev/null +++ b/db/migrate/20221206231914_add_enclosure_url_to_stories.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEnclosureUrlToStories < ActiveRecord::Migration[4.2] + def change + add_column(:stories, :enclosure_url, :string) + end +end diff --git a/db/migrate/20230120222742_drop_setup_complete.rb b/db/migrate/20230120222742_drop_setup_complete.rb new file mode 100644 index 000000000..580081160 --- /dev/null +++ b/db/migrate/20230120222742_drop_setup_complete.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DropSetupComplete < ActiveRecord::Migration[7.0] + def change + remove_column :users, :setup_complete, :boolean + end +end diff --git a/db/migrate/20230221233057_add_user_id_to_tables.rb b/db/migrate/20230221233057_add_user_id_to_tables.rb new file mode 100644 index 000000000..ad7e54f71 --- /dev/null +++ b/db/migrate/20230221233057_add_user_id_to_tables.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddUserIdToTables < ActiveRecord::Migration[7.0] + def change + add_reference :feeds, :user, index: true, foreign_key: true + add_reference :groups, :user, index: true, foreign_key: true + end +end diff --git a/db/migrate/20230223045525_add_null_false_to_associations.rb b/db/migrate/20230223045525_add_null_false_to_associations.rb new file mode 100644 index 000000000..0e754e0ee --- /dev/null +++ b/db/migrate/20230223045525_add_null_false_to_associations.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddNullFalseToAssociations < ActiveRecord::Migration[7.0] + def change + update_null_foreign_keys + + change_column_null :feeds, :user_id, false + change_column_null :groups, :user_id, false + change_column_null :stories, :feed_id, false + end + + private + + def update_null_foreign_keys + first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") + + return unless first_user_id + + connection.update("UPDATE feeds SET user_id = #{first_user_id} WHERE user_id IS NULL") + connection.update("UPDATE groups SET user_id = #{first_user_id} WHERE user_id IS NULL") + end +end diff --git a/db/migrate/20230223231930_add_username_to_users.rb b/db/migrate/20230223231930_add_username_to_users.rb new file mode 100644 index 000000000..b8e59c2e4 --- /dev/null +++ b/db/migrate/20230223231930_add_username_to_users.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddUsernameToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :username, :string + add_index :users, :username, unique: true + + set_default_username + + change_column_null :users, :username, false + end + + private + + def set_default_username + first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") + + return unless first_user_id + + connection.update("UPDATE users SET username = 'stringer' WHERE id = #{first_user_id}") + end +end diff --git a/db/migrate/20230224042638_update_unique_indexes.rb b/db/migrate/20230224042638_update_unique_indexes.rb new file mode 100644 index 000000000..7a4edaf5e --- /dev/null +++ b/db/migrate/20230224042638_update_unique_indexes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UpdateUniqueIndexes < ActiveRecord::Migration[7.0] + def change + remove_index :feeds, :url + add_index :feeds, [:url, :user_id], unique: true + add_index :groups, [:name, :user_id], unique: true + end +end diff --git a/db/migrate/20230301024452_encrypt_api_key.rb b/db/migrate/20230301024452_encrypt_api_key.rb new file mode 100644 index 000000000..7ce08243c --- /dev/null +++ b/db/migrate/20230301024452_encrypt_api_key.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class EncryptAPIKey < ActiveRecord::Migration[7.0] + def change + ActiveRecord::Encryption.config.support_unencrypted_data = true + + encrypt_api_keys + + ActiveRecord::Encryption.config.support_unencrypted_data = false + + change_column_null :users, :api_key, false + add_index :users, :api_key, unique: true + end + + private + + def encrypt_api_keys + connection.select_all("SELECT id, api_key FROM users").each do |user| + encrypted_api_key = ActiveRecord::Encryption.encryptor.encrypt(user["api_key"]) + connection.update("UPDATE users SET api_key = #{connection.quote(encrypted_api_key)} WHERE id = #{user['id']}") + end + end +end diff --git a/db/migrate/20230305010750_create_good_jobs.rb b/db/migrate/20230305010750_create_good_jobs.rb new file mode 100644 index 000000000..13020e603 --- /dev/null +++ b/db/migrate/20230305010750_create_good_jobs.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class CreateGoodJobs < ActiveRecord::Migration[7.0] + def change + enable_extension "pgcrypto" + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :performed_at + t.datetime :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + t.datetime :cron_at + + t.uuid :batch_id + t.uuid :batch_callback_id + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + + add_index :good_jobs, + :scheduled_at, + where: "(finished_at IS NULL)", + name: "index_good_jobs_on_scheduled_at" + add_index :good_jobs, + [:queue_name, :scheduled_at], + where: "(finished_at IS NULL)", + name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, + [:active_job_id, :created_at], + name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, + :concurrency_key, + where: "(finished_at IS NULL)", + name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, + [:cron_key, :created_at], + name: :index_good_jobs_on_cron_key_and_created_at + add_index :good_jobs, + [:cron_key, :cron_at], + name: :index_good_jobs_on_cron_key_and_cron_at, + unique: true + add_index :good_jobs, + [:active_job_id], + name: :index_good_jobs_on_active_job_id + add_index :good_jobs, + [:finished_at], + where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", + name: :index_good_jobs_jobs_on_finished_at + add_index :good_jobs, + [:priority, :created_at], + order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", + name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished + add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL" + add_index :good_jobs, + [:batch_callback_id], + where: "batch_callback_id IS NOT NULL" + end +end diff --git a/db/migrate/20230312193113_drop_delayed_job.rb b/db/migrate/20230312193113_drop_delayed_job.rb new file mode 100644 index 000000000..82a09bc1d --- /dev/null +++ b/db/migrate/20230312193113_drop_delayed_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DropDelayedJob < ActiveRecord::Migration[7.0] + def up + drop_table :delayed_jobs + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20230313034938_add_admin_to_users.rb b/db/migrate/20230313034938_add_admin_to_users.rb new file mode 100644 index 000000000..251d0dd8b --- /dev/null +++ b/db/migrate/20230313034938_add_admin_to_users.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddAdminToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :admin, :boolean, default: false + + set_first_user_as_admin + + change_column_null :users, :admin, false + end + + private + + def set_first_user_as_admin + first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") + + return unless first_user_id + + connection.update("UPDATE users SET admin = TRUE WHERE id = #{first_user_id}") + end +end diff --git a/db/migrate/20230330215830_create_subscriptions.rb b/db/migrate/20230330215830_create_subscriptions.rb new file mode 100644 index 000000000..0e74d4685 --- /dev/null +++ b/db/migrate/20230330215830_create_subscriptions.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateSubscriptions < ActiveRecord::Migration[7.0] + def change + create_table :subscriptions do |t| + t.references :user, + foreign_key: true, + null: false, + index: { unique: true } + t.text :stripe_customer_id, null: false + t.text :stripe_subscription_id, null: false + t.text :status, null: false + t.datetime :current_period_start, null: false + t.datetime :current_period_end, null: false + + t.timestamps + end + + add_index :subscriptions, :stripe_customer_id, unique: true + add_index :subscriptions, :stripe_subscription_id, unique: true + end +end diff --git a/db/migrate/20230721160939_create_settings.rb b/db/migrate/20230721160939_create_settings.rb new file mode 100644 index 000000000..cd2ec56f3 --- /dev/null +++ b/db/migrate/20230721160939_create_settings.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateSettings < ActiveRecord::Migration[7.0] + def change + create_table :settings do |t| + t.string :type, null: false, index: { unique: true } + t.jsonb :data, null: false, default: {} + + t.timestamps + end + end +end diff --git a/db/migrate/20230801025230_create_good_job_settings.rb b/db/migrate/20230801025230_create_good_job_settings.rb new file mode 100644 index 000000000..fa3540174 --- /dev/null +++ b/db/migrate/20230801025230_create_good_job_settings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateGoodJobSettings < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_settings) + end + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + end +end diff --git a/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb b/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb new file mode 100644 index 000000000..6da0c07ea --- /dev/null +++ b/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?( + :good_jobs, + :index_good_jobs_jobs_on_priority_created_at_when_unfinished + ) + end + end + + add_index :good_jobs, + [:priority, :created_at], + order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", + name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished, + algorithm: :concurrently + end +end diff --git a/db/migrate/20230801025232_create_good_job_batches.rb b/db/migrate/20230801025232_create_good_job_batches.rb new file mode 100644 index 000000000..ac6c5b37e --- /dev/null +++ b/db/migrate/20230801025232_create_good_job_batches.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateGoodJobBatches < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_batches) + end + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + change_table :good_jobs do |t| + t.uuid :batch_id + t.uuid :batch_callback_id + + t.index :batch_id, where: "batch_id IS NOT NULL" + t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL" + end + end +end diff --git a/db/migrate/20230801025233_create_good_job_executions.rb b/db/migrate/20230801025233_create_good_job_executions.rb new file mode 100644 index 000000000..af5c816cc --- /dev/null +++ b/db/migrate/20230801025233_create_good_job_executions.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutions < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.table_exists?(:good_job_executions) + end + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + + t.index [:active_job_id, :created_at], + name: :index_good_job_executions_on_active_job_id_and_created_at + end + + change_table :good_jobs do |t| + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + end + end +end diff --git a/db/migrate/20230801025234_create_good_jobs_error_event.rb b/db/migrate/20230801025234_create_good_jobs_error_event.rb new file mode 100644 index 000000000..b07e0f14e --- /dev/null +++ b/db/migrate/20230801025234_create_good_jobs_error_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.0] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :error_event) + end + end + + add_column :good_jobs, :error_event, :integer, limit: 2 + add_column :good_job_executions, :error_event, :integer, limit: 2 + end +end diff --git a/db/migrate/20240226201050_add_precision_to_timestamps.rb b/db/migrate/20240226201050_add_precision_to_timestamps.rb new file mode 100644 index 000000000..a90ed1ba3 --- /dev/null +++ b/db/migrate/20240226201050_add_precision_to_timestamps.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AddPrecisionToTimestamps < ActiveRecord::Migration[7.1] + SKIP_TABLES = [:schema_migrations, :ar_internal_metadata].freeze + + def up + migrate_precision(precision: 6) + end + + def down + migrate_precision(precision: nil) + end + + def migrate_precision(precision:) + table_names = ActiveRecord::Base.connection.tables.map(&:to_sym) + table_names.each do |table| + next if SKIP_TABLES.include?(table) + + ActiveRecord::Base.connection.columns(table).each do |column| + next unless datetime_column?(column) + + change_column(table, column.name, :datetime, precision:) + end + end + end + + private + + def datetime_column?(column) + column.sql_type_metadata.type == :datetime + end +end diff --git a/db/migrate/20240313195404_add_default_to_stories.rb b/db/migrate/20240313195404_add_default_to_stories.rb new file mode 100644 index 000000000..fbb396dab --- /dev/null +++ b/db/migrate/20240313195404_add_default_to_stories.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDefaultToStories < ActiveRecord::Migration[7.1] + def change + change_column_default :stories, :is_read, from: nil, to: false + end +end diff --git a/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb b/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb new file mode 100644 index 000000000..56cab35c3 --- /dev/null +++ b/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", + name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + add_index :good_jobs, [:cron_key, :created_at], + name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + add_index :good_jobs, [:cron_key, :cron_at], unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond + end + end + end + end +end diff --git a/db/migrate/20240314031220_create_good_job_labels.rb b/db/migrate/20240314031220_create_good_job_labels.rb new file mode 100644 index 000000000..1ba514e8b --- /dev/null +++ b/db/migrate/20240314031220_create_good_job_labels.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobLabels < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :labels) + end + end + + add_column :good_jobs, :labels, :text, array: true + end +end diff --git a/db/migrate/20240314031221_create_good_job_labels_index.rb b/db/migrate/20240314031221_create_good_job_labels_index.rb new file mode 100644 index 000000000..65dedd477 --- /dev/null +++ b/db/migrate/20240314031221_create_good_job_labels_index.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", + name: :index_good_jobs_on_labels, algorithm: :concurrently + end + end + + dir.down do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + remove_index :good_jobs, name: :index_good_jobs_on_labels + end + end + end + end +end diff --git a/db/migrate/20240314031222_remove_good_job_active_id_index.rb b/db/migrate/20240314031222_remove_good_job_active_id_index.rb new file mode 100644 index 000000000..8601f071a --- /dev/null +++ b/db/migrate/20240314031222_remove_good_job_active_id_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + remove_index :good_jobs, name: :index_good_jobs_on_active_job_id + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id + end + end + end + end +end diff --git a/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb b/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb new file mode 100644 index 000000000..70e525626 --- /dev/null +++ b/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup) + end + end + + add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup, + algorithm: :concurrently + end +end diff --git a/db/migrate/20240316211109_add_stories_order_to_users.rb b/db/migrate/20240316211109_add_stories_order_to_users.rb new file mode 100644 index 000000000..8aec04941 --- /dev/null +++ b/db/migrate/20240316211109_add_stories_order_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddStoriesOrderToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :stories_order, :string, default: "desc" + end +end diff --git a/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb b/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb new file mode 100644 index 000000000..619ddbe50 --- /dev/null +++ b/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + if connection.column_exists?(:good_job_executions, :error_backtrace) + return + end + end + end + + add_column :good_job_executions, :error_backtrace, :text, array: true + end +end diff --git a/db/migrate/20240709172406_create_good_job_process_lock_ids.rb b/db/migrate/20240709172406_create_good_job_process_lock_ids.rb new file mode 100644 index 000000000..f1b70a8f2 --- /dev/null +++ b/db/migrate/20240709172406_create_good_job_process_lock_ids.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :locked_by_id) + end + end + + add_column :good_jobs, :locked_by_id, :uuid + add_column :good_jobs, :locked_at, :datetime + add_column :good_job_executions, :process_id, :uuid + add_column :good_job_processes, :lock_type, :integer, limit: 2 + end +end diff --git a/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb b/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb new file mode 100644 index 000000000..ee7cd27e0 --- /dev/null +++ b/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + add_index :good_jobs, + [:priority, :scheduled_at], + order: { priority: "ASC NULLS LAST", scheduled_at: :asc }, + where: "finished_at IS NULL AND locked_by_id IS NULL", + name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + add_index :good_jobs, + :locked_by_id, + where: "locked_by_id IS NOT NULL", + name: :index_good_jobs_on_locked_by_id, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + add_index :good_job_executions, + [:process_id, :created_at], + name: :index_good_job_executions_on_process_id_and_created_at, + algorithm: :concurrently + end + end + + dir.down do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) + end + if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) + end + end + end + end +end diff --git a/db/migrate/20240709172408_create_good_job_execution_duration.rb b/db/migrate/20240709172408_create_good_job_execution_duration.rb new file mode 100644 index 000000000..fef37f07b --- /dev/null +++ b/db/migrate/20240709172408_create_good_job_execution_duration.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :duration) + end + end + + add_column :good_job_executions, :duration, :interval + end +end diff --git a/db/schema.rb b/db/schema.rb index 3f7ef303d..3563b1070 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,75 +2,180 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20141102103617) do - +ActiveRecord::Schema[7.1].define(version: 2024_07_09_172408) do # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" enable_extension "plpgsql" - create_table "delayed_jobs", force: true do |t| - t.integer "priority", default: 0 - t.integer "attempts", default: 0 - t.text "handler" - t.text "last_error" - t.datetime "run_at" - t.datetime "locked_at" - t.datetime "failed_at" - t.string "locked_by" - t.string "queue" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "feeds", id: :serial, force: :cascade do |t| + t.string "name", limit: 255 + t.text "url" + t.datetime "last_fetched" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "status" + t.integer "group_id" + t.bigint "user_id", null: false + t.index ["url", "user_id"], name: "index_feeds_on_url_and_user_id", unique: true + t.index ["user_id"], name: "index_feeds_on_user_id" end - add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + end - create_table "feeds", force: true do |t| - t.string "name" - t.text "url" - t.datetime "last_fetched" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "status" - t.integer "group_id" + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + t.integer "lock_type", limit: 2 + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true end - add_index "feeds", ["url"], name: "index_feeds_on_url", unique: true, using: :btree + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" + end - create_table "groups", force: true do |t| - t.string "name", null: false + create_table "groups", id: :serial, force: :cascade do |t| + t.string "name", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["name", "user_id"], name: "index_groups_on_name_and_user_id", unique: true + t.index ["user_id"], name: "index_groups_on_user_id" end - create_table "stories", force: true do |t| - t.text "title" - t.text "permalink" - t.text "body" - t.integer "feed_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "settings", force: :cascade do |t| + t.string "type", null: false + t.jsonb "data", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["type"], name: "index_settings_on_type", unique: true + end + + create_table "stories", id: :serial, force: :cascade do |t| + t.text "title" + t.text "permalink" + t.text "body" + t.integer "feed_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.datetime "published" - t.boolean "is_read" - t.boolean "keep_unread", default: false - t.boolean "is_starred", default: false - t.text "entry_id" + t.boolean "is_read", default: false + t.boolean "keep_unread", default: false + t.boolean "is_starred", default: false + t.text "entry_id" + t.string "enclosure_url" + t.index ["entry_id", "feed_id"], name: "index_stories_on_entry_id_and_feed_id", unique: true end - add_index "stories", ["entry_id", "feed_id"], name: "index_stories_on_entry_id_and_feed_id", unique: true, using: :btree + create_table "subscriptions", force: :cascade do |t| + t.bigint "user_id", null: false + t.text "stripe_customer_id", null: false + t.text "stripe_subscription_id", null: false + t.text "status", null: false + t.datetime "current_period_start", null: false + t.datetime "current_period_end", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id", unique: true + t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true + t.index ["user_id"], name: "index_subscriptions_on_user_id", unique: true + end - create_table "users", force: true do |t| - t.string "password_digest" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "setup_complete" - t.string "api_key" + create_table "users", id: :serial, force: :cascade do |t| + t.string "password_digest", limit: 255 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "api_key", limit: 255, null: false + t.string "username", null: false + t.boolean "admin", null: false + t.string "stories_order", default: "desc" + t.index ["api_key"], name: "index_users_on_api_key", unique: true + t.index ["username"], name: "index_users_on_username", unique: true end + add_foreign_key "feeds", "users" + add_foreign_key "groups", "users" + add_foreign_key "subscriptions", "users" end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..8e9b8f90f --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1 @@ +# frozen_string_literal: true diff --git a/docker-compose.yml b/docker-compose.yml index 06ac17b91..f93f33a37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,49 @@ -version: '2' +version: '3' + services: - postgres: - image: postgres:9.5-alpine + stringer-setup: + image: stringerrss/stringer:latest + container_name: stringer-setup + restart: no + env_file: .env + volumes: + - ./.env:/app/.env + entrypoint: ["ruby"] + command: ["/app/docker/init_or_update_env.rb"] + + stringer-postgres: + image: postgres:16-alpine + container_name: stringer-postgres restart: always + depends_on: + stringer-setup: + condition: service_completed_successfully + networks: + - stringer-network volumes: - - ~/stringer:/var/lib/postgresql/data - environment: - - POSTGRES_PASSWORD=super_secret_password - - POSTGRES_USER=db_user - - POSTGRES_DB=stringer + - ./.env:/app/.env + - /srv/stringer/data:/var/lib/postgresql/data + env_file: .env - web: - image: mdswanson/stringer + stringer: + image: stringerrss/stringer:latest + container_name: stringer + build: . depends_on: - - postgres + stringer-postgres: + condition: service_started + stringer-setup: + condition: service_completed_successfully restart: always ports: - 80:8080 - environment: - - SECRET_TOKEN=YOUR_SECRET_TOKEN - - PORT=8080 - - DATABASE_URL=postgres://db_user:super_secret_password@postgres:5432/stringer + networks: + - stringer-network + volumes: + - ./.env:/app/.env + env_file: .env + +networks: + stringer-network: + external: false + name: stringer-network diff --git a/docker/init_or_update_env.rb b/docker/init_or_update_env.rb new file mode 100644 index 000000000..6b2845c6b --- /dev/null +++ b/docker/init_or_update_env.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Secrets + def self.generate_secret(length) + `openssl rand -hex #{length}`.strip + end +end + +pg_user = ENV.fetch("POSTGRES_USER", "stringer") +pg_password = ENV.fetch("POSTGRES_PASSWORD", Secrets.generate_secret(32)) +pg_host = ENV.fetch("POSTGRES_HOSTNAME", "stringer-postgres") +pg_db = ENV.fetch("POSTGRES_DB", "stringer") + +required_env = { + "SECRET_KEY_BASE" => Secrets.generate_secret(64), + "ENCRYPTION_PRIMARY_KEY" => Secrets.generate_secret(64), + "ENCRYPTION_DETERMINISTIC_KEY" => Secrets.generate_secret(64), + "ENCRYPTION_KEY_DERIVATION_SALT" => Secrets.generate_secret(64), + "POSTGRES_USER" => pg_user, + "POSTGRES_PASSWORD" => pg_password, + "POSTGRES_HOSTNAME" => pg_host, + "POSTGRES_DB" => pg_db, + "FETCH_FEEDS_CRON" => "*/5 * * * *", + "CLEANUP_CRON" => "0 0 * * *", + "DATABASE_URL" => "postgres://#{pg_user}:#{pg_password}@#{pg_host}/#{pg_db}" +} + +required_env.each do |key, value| + next if ENV.key?(key) + + File.open("/app/.env", "a") { |file| file << "#{key}=#{value}\n" } +end diff --git a/docker/start.sh b/docker/start.sh index fb05a3c35..1d603aea2 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -2,14 +2,13 @@ if [ -z "$DATABASE_URL" ]; then cat <<-EOF $(tput setaf 1)Error: no DATABASE_URL was specified. - - For a quick start use DATABASE_URL="sqlite3:':memory:'" - (not recommended for production). EOF exit 1 fi +rails assets:precompile + : ${FETCH_FEEDS_CRON:='*/5 * * * *'} : ${CLEANUP_CRON:='0 0 * * *'} diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 61f5b300d..12c76be16 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -2,8 +2,8 @@ nodaemon=true loglevel=debug -[program:unicorn] -command=bash -c 'bundle exec rake db:migrate && bundle exec unicorn -p $PORT -c ./config/unicorn.rb' +[program:puma] +command=bash -c 'bundle exec rake db:migrate && bundle exec puma -p $PORT -C ./config/puma.rb' directory=/app autostart=true autorestart=true diff --git a/docs/docker.md b/docs/Docker.md similarity index 78% rename from docs/docker.md rename to docs/Docker.md index 606e51576..a146bd741 100644 --- a/docs/docker.md +++ b/docs/Docker.md @@ -1,21 +1,16 @@ # Stringer on Docker -## Quick test setup +## Production ready setup using docker-compose + +Create a local environment file named `.env`, e.g. via `touch .env`. -To quickly try out Stringer on your local machine run the following one liner: +Download [docker-compose.yml](../docker-compose.yml) and run: ```sh -docker run --rm -it -e DATABASE_URL="sqlite3:':memory:'" -p 8080:8080 mdswanson/stringer +touch .env && docker compose up -d ``` -Visit `http://localhost:8080` and enjoy Stringer! - -**One caveat**: Stringer was not designed to be used with sqlite so you might run into some issues if you -have Stringer fetch many feeds. See [this issue](https://github.com/swanson/stringer/issues/164) for details. - -## Production ready setup using docker-compose - -Download [docker-compose.yml](../docker-compose.yml) and in the corresponding foler, run `docker-compose up -d`, give it a second and visit `localhost` +Give it a second and visit `localhost`! ## Production ready manual setup @@ -23,9 +18,9 @@ The following steps can be used to setup Stringer on Docker, using a Postgres da 1. Setup a Docker network so the two containers we're going to create can communicate: - ```Sh - docker network create --driver bridge stringer - ``` +```Sh +docker network create --driver bridge stringer-network +``` 2. Setup a Postgres Docker container: @@ -34,10 +29,10 @@ docker run --detach \ --name stringer-postgres \ --restart always \ --volume /srv/stringer/data:/var/lib/postgresql/data \ - --net stringer \ + --net stringer-network \ -e POSTGRES_PASSWORD=myPassword \ -e POSTGRES_DB=stringer \ - postgres:9.5-alpine + postgres:16-alpine ``` 3. Run the Stringer Docker image: @@ -45,15 +40,18 @@ docker run --detach \ ```sh docker run --detach \ --name stringer \ - --net stringer \ + --net stringer-network \ --restart always \ -e PORT=8080 \ -e DATABASE_URL=postgres://postgres:myPassword@stringer-postgres/stringer \ - -e SECRET_TOKEN=$(openssl rand -hex 20) \ + -e SECRET_KEY_BASE=$(openssl rand -hex 64) \ + -e ENCRYPTION_PRIMARY_KEY=$(openssl rand -hex 64) \ + -e ENCRYPTION_DETERMINISTIC_KEY=$(openssl rand -hex 64) \ + -e ENCRYPTION_KEY_DERIVATION_SALT=$(openssl rand -hex 64) \ -e FETCH_FEEDS_CRON="*/5 * * * *" \ # optional -e CLEANUP_CRON="0 0 * * *" \ # optional -p 127.0.0.1:8080:8080 \ - mdswanson/stringer + stringerrss/stringer:latest ``` That's it! You now have a fully working Stringer instance up and running! diff --git a/docs/Heroku.md b/docs/Heroku.md index 4a113e20e..a63923f81 100644 --- a/docs/Heroku.md +++ b/docs/Heroku.md @@ -1,11 +1,16 @@ ```sh -git clone git://github.com/swanson/stringer.git +git clone git@github.com:stringer-rss/stringer.git cd stringer heroku create -git push heroku master -heroku config:set APP_URL=`heroku apps:info --shell | grep web-url | cut -d= -f2` -heroku config:set SECRET_TOKEN=`openssl rand -hex 20` +heroku config:set SECRET_KEY_BASE=`openssl rand -hex 64` +heroku config:set ENCRYPTION_PRIMARY_KEY=`openssl rand -hex 64` +heroku config:set ENCRYPTION_DETERMINISTIC_KEY=`openssl rand -hex 64` +heroku config:set ENCRYPTION_KEY_DERIVATION_SALT=`openssl rand -hex 64` + +git push heroku main + +heroku config:set APP_URL=`heroku apps:info --shell | grep web_url | cut -d= -f2` heroku run rake db:migrate heroku restart @@ -26,11 +31,7 @@ From the app's directory: ```sh git pull -git push heroku master +git push heroku main heroku run rake db:migrate heroku restart ``` - -## Password Reset - -In the event that you need to change your password, run `heroku run rake change_password` from the app folder. diff --git a/docs/OpenShift.md b/docs/OpenShift.md index be9d7dbfe..3eb056a29 100644 --- a/docs/OpenShift.md +++ b/docs/OpenShift.md @@ -14,15 +14,15 @@ Deploying into OpenShift ```sh cd feeds - git remote add upstream git://github.com/swanson/stringer.git - git pull -s recursive -X theirs upstream master + git remote add upstream git@github.com:stringer-rss/stringer.git + git pull -s recursive -X theirs upstream main ``` 3. To enable migrations for the application, a new action_hook is required. Add the file, .openshift/action_hooks/deploy, with the below 3 lines into it. ``` pushd ${OPENSHIFT_REPO_DIR} > /dev/null - bundle exec rake db:migrate RACK_ENV="production" + bundle exec rake db:migrate RAILS_ENV="production" popd > /dev/null ``` @@ -32,10 +32,13 @@ Deploying into OpenShift chmod +x .openshift/action_hooks/deploy ``` -5. Set the SECRET_TOKEN as a rhc environment variable by generating it with the command below. +5. Set the environment variables by generating them with the commands below. ```sh - rhc env set SECRET_TOKEN="`openssl rand -hex 20`" + rhc env set SECRET_KEY_BASE="`openssl rand -hex 64`" + rhc env set ENCRYPTION_PRIMARY_KEY="`openssl rand -hex 64`" + rhc env set ENCRYPTION_DETERMINISTIC_KEY="`openssl rand -hex 64`" + rhc env set ENCRYPTION_KEY_DERIVATION_SALT="`openssl rand -hex 64`" ``` 6. Configuration of the database server is next. Open the file config/database.yml and add in the configuration for Production as shown below. OpenShift is able to use environment variables to push the information into the application. @@ -99,7 +102,7 @@ After importing feeds, a cron job is needed on OpenShift to fetch feeds. ``` ./usr/bin/rhcsh pushd ${OPENSHIFT_REPO_DIR} > /dev/null - bundle exec rake fetch_feeds RACK_ENV="production" + bundle exec rake fetch_feeds RAILS_ENV="production" popd > /dev/null ``` @@ -118,12 +121,3 @@ After importing feeds, a cron job is needed on OpenShift to fetch feeds. ``` 5. Done! The cron job should fetch feeds every hour. - -Password Reset --------------- -In the event that you need to change your password, run the following commands -``` -rhc ssh feeds -cd app-root/repo -bundle exec rake change_password RACK_ENV="production" -``` diff --git a/docs/VPS.md b/docs/VPS.md index 752f91550..7e4a0ab92 100644 --- a/docs/VPS.md +++ b/docs/VPS.md @@ -5,11 +5,11 @@ The first step is installing some essential dependencies from your VPS's package #### Ubuntu/Debian - sudo apt-get install git libxml2-dev libxslt-dev libcurl4-openssl-dev libpq-dev libsqlite3-dev build-essential postgresql libreadline-dev + sudo apt-get install git libxml2-dev libxslt-dev libcurl4-openssl-dev libpq-dev build-essential postgresql libreadline-dev #### CentOS/Fedora - sudo yum install git libxml2-devel libxslt-devel curl-devel postgresql-devel sqlite-devel make automake gcc gcc-c++ postgresql-server readline-devel openssl-devel + sudo yum install git libxml2-devel libxslt-devel curl-devel postgresql-devel make automake gcc gcc-c++ postgresql-server readline-devel openssl-devel On CentOS after installing Postgres, I needed to run these commands, Fedora likely the same. @@ -17,7 +17,7 @@ On CentOS after installing Postgres, I needed to run these commands, Fedora like #### Arch Linux - pacman -S git postgresql base-devel libxml2 libxslt curl sqlite readline postgresql-libs + pacman -S git postgresql base-devel libxml2 libxslt curl readline postgresql-libs Here are some Arch specific instructions for setting up postgres @@ -61,8 +61,8 @@ We are going to use Rbenv to manage the version of Ruby you use. git clone git://github.com/sstephenson/ruby-build.git $HOME/.rbenv/plugins/ruby-build source ~/.bash_profile - rbenv install 2.3.3 - rbenv local 2.3.3 + rbenv install 2.7.5 + rbenv local 2.7.5 rbenv rehash We also need to install bundler which will handle Stringer's dependencies @@ -79,7 +79,7 @@ Install Stringer and set it up Grab Stringer from github - git clone https://github.com/swanson/stringer.git + git clone git@github.com:stringer-rss/stringer.git cd stringer Use bundler to grab and build Stringer's dependencies @@ -91,13 +91,17 @@ Stringer uses environment variables to configure the application. Edit these val echo 'export DATABASE_URL="postgres://stringer:EDIT_ME@localhost/stringer_live"' >> $HOME/.bash_profile echo 'export RACK_ENV="production"' >> $HOME/.bash_profile - echo "export SECRET_TOKEN=`openssl rand -hex 20`" >> $HOME/.bash_profile + echo 'export RAILS_ENV="production"' >> $HOME/.bash_profile + echo "export SECRET_KEY_BASE=`openssl rand -hex 64`" >> $HOME/.bash_profile + echo "export ENCRYPTION_PRIMARY_KEY=`openssl rand -hex 64`" >> $HOME/.bash_profile + echo "export ENCRYPTION_DETERMINISTIC_KEY=`openssl rand -hex 64`" >> $HOME/.bash_profile + echo "export ENCRYPTION_KEY_DERIVATION_SALT=`openssl rand -hex 64`" >> $HOME/.bash_profile source ~/.bash_profile Tell stringer to run the database in production mode, using the `postgres` database you created earlier. cd $HOME/stringer - rake db:migrate RACK_ENV=production + rake db:migrate RAILS_ENV=production Run the application: @@ -130,7 +134,7 @@ Logout stringer user, install systemd services: As stringer user, close existing Stringer instance: - exit # exit racksh and app + exit # exit app Start app as a systemd service and make app run at startup @@ -163,13 +167,13 @@ server { Deploy Stringer With Passenger And Apache ========================================= -You may want to run Stringer with the existing Apache server. We need to install +You may want to run Stringer with the existing Apache server. We need to install *mod_passenger* and edit few files. The installation of *mod_passenger* depends on VPS's system distribution release. Offical installation guide is available at [Passenger Library](https://www.phusionpassenger.com/library/install/apache/install/oss/) -After validating the *mod_passenger* install, we will fetch dependencies again +After validating the *mod_passenger* install, we will fetch dependencies again to meet Passenger's default GEM_HOME set. As stringer user: cd ~/stringer @@ -185,14 +189,14 @@ Add VirtualHost to your Apache installation, here's a sample configuration: ```bash ServerName example.com - DocumentRoot /home/stringer/stringer/app/public + DocumentRoot /home/stringer/stringer/public PassengerEnabled On PassengerAppRoot /home/stringer/stringer PassengerRuby /home/stringer/.rbenv/shims/ruby # PassengerLogFile /dev/null # don't flow logs to apache error.log - + Options FollowSymLinks Require all granted AllowOverride All diff --git a/fever_api.rb b/fever_api.rb deleted file mode 100644 index 6de858ee3..000000000 --- a/fever_api.rb +++ /dev/null @@ -1,41 +0,0 @@ -require "sinatra/base" -require "sinatra/activerecord" - -require_relative "./app/fever_api/response" - -module FeverAPI - class Endpoint < Sinatra::Base - configure do - set :database_file, "config/database.yml" - - register Sinatra::ActiveRecordExtension - ActiveRecord::Base.include_root_in_json = false - end - - before do - headers = { "Content-Type" => "application/json" } - body = { api_version: FeverAPI::API_VERSION, auth: 0 }.to_json - halt 200, headers, body unless authenticated?(params[:api_key]) - end - - def authenticated?(api_key) - return unless api_key - user = User.first - user.api_key && api_key.casecmp(user.api_key).zero? - end - - get "/" do - content_type :json - build_response(params) - end - - post "/" do - content_type :json - build_response(params) - end - - def build_response(params) - FeverAPI::Response.new(params).to_json - end - end -end diff --git a/lib/admin_constraint.rb b/lib/admin_constraint.rb new file mode 100644 index 000000000..0b17c2ade --- /dev/null +++ b/lib/admin_constraint.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AdminConstraint + def matches?(request) + request.session.key?(:user_id) && + User.find(request.session[:user_id]).admin? + end +end diff --git a/lib/assets/.keep b/lib/assets/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/log/.keep b/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/public/400.html b/public/400.html new file mode 100644 index 000000000..282dbc8cc --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
            +
            + +
            +
            +

            The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

            +
            +
            + + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..c0670bc87 --- /dev/null +++ b/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
            +
            + +
            +
            +

            The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re 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 000000000..9532a9ccd --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
            +
            + +
            +
            +

            Your browser is not supported.
            Please upgrade your browser to continue.

            +
            +
            + + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 000000000..8bcf06014 --- /dev/null +++ b/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
            +
            + +
            +
            +

            The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

            +
            +
            + + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 000000000..d77718c3a --- /dev/null +++ b/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
            +
            + +
            +
            +

            We’re sorry, but something went wrong.
            If you’re the application owner check the logs for more information.

            +
            +
            + + + + diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 000000000..e69de29bb diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 000000000..e69de29bb diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..e69de29bb diff --git a/app/public/fonts/FontAwesome.otf b/public/fonts/FontAwesome.otf similarity index 100% rename from app/public/fonts/FontAwesome.otf rename to public/fonts/FontAwesome.otf diff --git a/app/public/fonts/fontawesome-webfont.eot b/public/fonts/fontawesome-webfont.eot similarity index 100% rename from app/public/fonts/fontawesome-webfont.eot rename to public/fonts/fontawesome-webfont.eot diff --git a/app/public/fonts/fontawesome-webfont.svg b/public/fonts/fontawesome-webfont.svg similarity index 100% rename from app/public/fonts/fontawesome-webfont.svg rename to public/fonts/fontawesome-webfont.svg diff --git a/app/public/fonts/fontawesome-webfont.ttf b/public/fonts/fontawesome-webfont.ttf similarity index 100% rename from app/public/fonts/fontawesome-webfont.ttf rename to public/fonts/fontawesome-webfont.ttf diff --git a/app/public/fonts/fontawesome-webfont.woff b/public/fonts/fontawesome-webfont.woff similarity index 100% rename from app/public/fonts/fontawesome-webfont.woff rename to public/fonts/fontawesome-webfont.woff diff --git a/app/public/fonts/lato/Lato-Black.eot b/public/fonts/lato/Lato-Black.eot similarity index 100% rename from app/public/fonts/lato/Lato-Black.eot rename to public/fonts/lato/Lato-Black.eot diff --git a/app/public/fonts/lato/Lato-Black.ttf b/public/fonts/lato/Lato-Black.ttf similarity index 100% rename from app/public/fonts/lato/Lato-Black.ttf rename to public/fonts/lato/Lato-Black.ttf diff --git a/app/public/fonts/lato/Lato-Black.woff b/public/fonts/lato/Lato-Black.woff similarity index 100% rename from app/public/fonts/lato/Lato-Black.woff rename to public/fonts/lato/Lato-Black.woff diff --git a/app/public/fonts/lato/Lato-Black.woff2 b/public/fonts/lato/Lato-Black.woff2 similarity index 100% rename from app/public/fonts/lato/Lato-Black.woff2 rename to public/fonts/lato/Lato-Black.woff2 diff --git a/app/public/fonts/lato/Lato-Bold.eot b/public/fonts/lato/Lato-Bold.eot similarity index 100% rename from app/public/fonts/lato/Lato-Bold.eot rename to public/fonts/lato/Lato-Bold.eot diff --git a/app/public/fonts/lato/Lato-Bold.ttf b/public/fonts/lato/Lato-Bold.ttf similarity index 100% rename from app/public/fonts/lato/Lato-Bold.ttf rename to public/fonts/lato/Lato-Bold.ttf diff --git a/app/public/fonts/lato/Lato-Bold.woff b/public/fonts/lato/Lato-Bold.woff similarity index 100% rename from app/public/fonts/lato/Lato-Bold.woff rename to public/fonts/lato/Lato-Bold.woff diff --git a/app/public/fonts/lato/Lato-Bold.woff2 b/public/fonts/lato/Lato-Bold.woff2 similarity index 100% rename from app/public/fonts/lato/Lato-Bold.woff2 rename to public/fonts/lato/Lato-Bold.woff2 diff --git a/app/public/fonts/lato/Lato-Italic.eot b/public/fonts/lato/Lato-Italic.eot similarity index 100% rename from app/public/fonts/lato/Lato-Italic.eot rename to public/fonts/lato/Lato-Italic.eot diff --git a/app/public/fonts/lato/Lato-Italic.ttf b/public/fonts/lato/Lato-Italic.ttf similarity index 100% rename from app/public/fonts/lato/Lato-Italic.ttf rename to public/fonts/lato/Lato-Italic.ttf diff --git a/app/public/fonts/lato/Lato-Italic.woff b/public/fonts/lato/Lato-Italic.woff similarity index 100% rename from app/public/fonts/lato/Lato-Italic.woff rename to public/fonts/lato/Lato-Italic.woff diff --git a/app/public/fonts/lato/Lato-Italic.woff2 b/public/fonts/lato/Lato-Italic.woff2 similarity index 100% rename from app/public/fonts/lato/Lato-Italic.woff2 rename to public/fonts/lato/Lato-Italic.woff2 diff --git a/app/public/fonts/lato/Lato-Light.eot b/public/fonts/lato/Lato-Light.eot similarity index 100% rename from app/public/fonts/lato/Lato-Light.eot rename to public/fonts/lato/Lato-Light.eot diff --git a/app/public/fonts/lato/Lato-Light.ttf b/public/fonts/lato/Lato-Light.ttf similarity index 100% rename from app/public/fonts/lato/Lato-Light.ttf rename to public/fonts/lato/Lato-Light.ttf diff --git a/app/public/fonts/lato/Lato-Light.woff b/public/fonts/lato/Lato-Light.woff similarity index 100% rename from app/public/fonts/lato/Lato-Light.woff rename to public/fonts/lato/Lato-Light.woff diff --git a/app/public/fonts/lato/Lato-Light.woff2 b/public/fonts/lato/Lato-Light.woff2 similarity index 100% rename from app/public/fonts/lato/Lato-Light.woff2 rename to public/fonts/lato/Lato-Light.woff2 diff --git a/app/public/fonts/lato/Lato-Regular.eot b/public/fonts/lato/Lato-Regular.eot similarity index 100% rename from app/public/fonts/lato/Lato-Regular.eot rename to public/fonts/lato/Lato-Regular.eot diff --git a/app/public/fonts/lato/Lato-Regular.ttf b/public/fonts/lato/Lato-Regular.ttf similarity index 100% rename from app/public/fonts/lato/Lato-Regular.ttf rename to public/fonts/lato/Lato-Regular.ttf diff --git a/app/public/fonts/lato/Lato-Regular.woff b/public/fonts/lato/Lato-Regular.woff similarity index 100% rename from app/public/fonts/lato/Lato-Regular.woff rename to public/fonts/lato/Lato-Regular.woff diff --git a/app/public/fonts/lato/Lato-Regular.woff2 b/public/fonts/lato/Lato-Regular.woff2 similarity index 100% rename from app/public/fonts/lato/Lato-Regular.woff2 rename to public/fonts/lato/Lato-Regular.woff2 diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.eot b/public/fonts/reenie-beanie/ReenieBeanie.eot similarity index 100% rename from app/public/fonts/reenie-beanie/ReenieBeanie.eot rename to public/fonts/reenie-beanie/ReenieBeanie.eot diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.svg b/public/fonts/reenie-beanie/ReenieBeanie.svg similarity index 100% rename from app/public/fonts/reenie-beanie/ReenieBeanie.svg rename to public/fonts/reenie-beanie/ReenieBeanie.svg diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.ttf b/public/fonts/reenie-beanie/ReenieBeanie.ttf similarity index 100% rename from app/public/fonts/reenie-beanie/ReenieBeanie.ttf rename to public/fonts/reenie-beanie/ReenieBeanie.ttf diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.woff b/public/fonts/reenie-beanie/ReenieBeanie.woff similarity index 100% rename from app/public/fonts/reenie-beanie/ReenieBeanie.woff rename to public/fonts/reenie-beanie/ReenieBeanie.woff diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.woff2 b/public/fonts/reenie-beanie/ReenieBeanie.woff2 similarity index 100% rename from app/public/fonts/reenie-beanie/ReenieBeanie.woff2 rename to public/fonts/reenie-beanie/ReenieBeanie.woff2 diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 000000000..c4c9dbfbb Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/public/img/apple-touch-icon-precomposed.png b/public/img/apple-touch-icon-precomposed.png similarity index 100% rename from app/public/img/apple-touch-icon-precomposed.png rename to public/img/apple-touch-icon-precomposed.png diff --git a/app/public/img/arrow-left.png b/public/img/arrow-left.png similarity index 100% rename from app/public/img/arrow-left.png rename to public/img/arrow-left.png diff --git a/app/public/img/arrow-right-up.png b/public/img/arrow-right-up.png similarity index 100% rename from app/public/img/arrow-right-up.png rename to public/img/arrow-right-up.png diff --git a/app/public/img/arrow-right.png b/public/img/arrow-right.png similarity index 100% rename from app/public/img/arrow-right.png rename to public/img/arrow-right.png diff --git a/app/public/img/arrow-up-left.png b/public/img/arrow-up-left.png similarity index 100% rename from app/public/img/arrow-up-left.png rename to public/img/arrow-up-left.png diff --git a/app/public/img/arrow-up-right.png b/public/img/arrow-up-right.png similarity index 100% rename from app/public/img/arrow-up-right.png rename to public/img/arrow-up-right.png diff --git a/app/public/img/favicon.png b/public/img/favicon.png similarity index 100% rename from app/public/img/favicon.png rename to public/img/favicon.png diff --git a/app/public/img/glyphicons-halflings-white.png b/public/img/glyphicons-halflings-white.png similarity index 100% rename from app/public/img/glyphicons-halflings-white.png rename to public/img/glyphicons-halflings-white.png diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..e6d6c8261 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Stringer", + "theme_color": "#F67100", + "background_color": "#FAF2E5", + "display": "standalone", + "start_url": "/" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/spec/commands/cast_boolean_spec.rb b/spec/commands/cast_boolean_spec.rb new file mode 100644 index 000000000..6eec442e5 --- /dev/null +++ b/spec/commands/cast_boolean_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe CastBoolean do + ["true", true, "1"].each do |true_value| + it "returns true when passed #{true_value.inspect}" do + expect(described_class.call(true_value)).to be(true) + end + end + + ["false", false, "0"].each do |false_value| + it "returns false when passed #{false_value.inspect}" do + expect(described_class.call(false_value)).to be(false) + end + end + + it "raises an error when passed non-boolean value" do + ["butt", 0, nil, "", []].each do |bad_value| + expected_message = "cannot cast to boolean: #{bad_value.inspect}" + expect { described_class.call(bad_value) } + .to raise_error(ArgumentError, expected_message) + end + end +end diff --git a/spec/commands/feed/create_spec.rb b/spec/commands/feed/create_spec.rb new file mode 100644 index 000000000..f2b342c70 --- /dev/null +++ b/spec/commands/feed/create_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe Feed::Create do + context "feed cannot be discovered" do + it "returns false if cant discover any feeds" do + expect(FeedDiscovery).to receive(:call).and_return(false) + result = described_class.call("http://not-a-feed.com", user: default_user) + + expect(result).to be(false) + end + end + + context "feed can be discovered" do + it "parses and creates the feed if discovered" do + feed = build(:feed) + feed_result = double(title: feed.name, feed_url: feed.url) + expect(FeedDiscovery).to receive(:call).and_return(feed_result) + + expect { described_class.call("http://feed.com", user: default_user) } + .to change(Feed, :count).by(1) + end + + context "title includes a script tag" do + it "deletes the script tag from the title" do + feed = + double(title: "foobar", feed_url: nil) + + expect(FeedDiscovery).to receive(:call).and_return(feed) + + feed = described_class.call("http://feed.com", user: default_user) + + expect(feed.name).to eq("foobar") + end + end + end + + it "uses feed_url as name when title is not present" do + feed_url = "https://protomen.com/news/feed" + result = instance_double(Feedjira::Parser::RSS, title: nil, feed_url:) + expect(FeedDiscovery).to receive(:call).and_return(result) + + expect { described_class.call(feed_url, user: default_user) } + .to change(Feed, :count).by(1) + + expect(Feed.last.name).to eq(feed_url) + end +end diff --git a/spec/commands/feed/export_to_opml_spec.rb b/spec/commands/feed/export_to_opml_spec.rb new file mode 100644 index 000000000..79ca42985 --- /dev/null +++ b/spec/commands/feed/export_to_opml_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe Feed::ExportToOpml do + it "returns OPML XML" do + feed_one, feed_two = build_pair(:feed) + result = described_class.call([feed_one, feed_two]) + + outlines = Nokogiri.XML(result).xpath("//body//outline") + expect(outlines.size).to eq(2) + expect(outlines.first["title"]).to eq(feed_one.name) + expect(outlines.first["xmlUrl"]).to eq(feed_one.url) + expect(outlines.last["title"]).to eq(feed_two.name) + expect(outlines.last["xmlUrl"]).to eq(feed_two.url) + end + + it "handles empty feeds" do + result = described_class.call([]) + + outlines = Nokogiri.XML(result).xpath("//body//outline") + expect(outlines).to be_empty + end + + it "has a proper title" do + feed_one, feed_two = build_pair(:feed) + result = described_class.call([feed_one, feed_two]) + + title = Nokogiri.XML(result).xpath("//head//title").first + expect(title.content).to eq("Feeds from Stringer") + end +end diff --git a/spec/commands/feed/fetch_all_for_user_spec.rb b/spec/commands/feed/fetch_all_for_user_spec.rb new file mode 100644 index 000000000..9cc0a9371 --- /dev/null +++ b/spec/commands/feed/fetch_all_for_user_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Feed::FetchAllForUser do + def stub_pool + pool = instance_double(Thread::Pool) + expect(Thread).to receive(:pool).and_return(pool) + expect(pool).to receive(:process).at_least(:once).and_yield + expect(pool).to receive(:shutdown) + end + + it "calls Feed::FetchOne for every feed" do + stub_pool + feed1, feed2 = create_pair(:feed) + + expect { described_class.call(default_user) } + .to invoke(:call).on(Feed::FetchOne).with(feed1) + .and invoke(:call).on(Feed::FetchOne).with(feed2) + end +end diff --git a/spec/commands/feed/fetch_all_spec.rb b/spec/commands/feed/fetch_all_spec.rb new file mode 100644 index 000000000..2232b7a91 --- /dev/null +++ b/spec/commands/feed/fetch_all_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Feed::FetchAll do + def stub_pool + pool = instance_double(Thread::Pool) + expect(Thread).to receive(:pool).and_return(pool) + expect(pool).to receive(:process).at_least(:once).and_yield + expect(pool).to receive(:shutdown) + end + + it "calls Feed::FetchOne for every feed" do + stub_pool + feed1, feed2 = create_pair(:feed) + + expect { described_class.call } + .to invoke(:call).on(Feed::FetchOne).with(feed1) + .and invoke(:call).on(Feed::FetchOne).with(feed2) + end +end diff --git a/spec/commands/feed/fetch_one_spec.rb b/spec/commands/feed/fetch_one_spec.rb new file mode 100644 index 000000000..12257a74f --- /dev/null +++ b/spec/commands/feed/fetch_one_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.describe Feed::FetchOne do + include ActiveSupport::Testing::TimeHelpers + + def create_entry(**options) + entry = { + published: Time.zone.now, + title: "Run LLMs on my own Mac fast and efficient", + url: "https://www.secondstate.io/articles/fast-llm-inference", + content: "", + id: "test", + **options + } + double(entry) + end + + def stub_raw_feed(feed, entries: []) + xml = GenerateXml.call(feed, entries) + stub_request(:get, feed.url).to_return(status: 200, body: xml) + end + + context "when no new posts have been added" do + it "does not add any new posts" do + feed = create(:feed) + stub_raw_feed(feed) + + expect { described_class.call(feed) }.not_to change(feed.stories, :count) + end + + it "does not add posts that are old" do + feed = create(:feed) + entry = create_entry(published: Time.zone.local(2013, 1, 1)) + stub_raw_feed(feed, entries: [entry]) + + expect { described_class.call(feed) }.not_to change(feed.stories, :count) + end + end + + context "when new posts have been added" do + it "only adds posts that are new" do + feed = create(:feed) + stub_raw_feed(feed, entries: [create_entry]) + + expect { described_class.call(feed) } + .to change(feed.stories, :count).by(1) + end + + it "updates the last fetched time for the feed" do + feed = create(:feed, last_fetched: Time.zone.local(2013, 1, 1)) + freeze_time + stub_raw_feed(feed, entries: [create_entry]) + + expect { described_class.call(feed) } + .to change(feed, :last_fetched).to(Time.zone.now) + end + end + + context "feed status" do + it "sets the status to green if things are all good" do + feed = create(:feed) + stub_raw_feed(feed) + + expect { described_class.call(feed) }.to change(feed, :status).to("green") + end + + it "sets the status to red if things go wrong" do + feed = create(:feed) + allow(described_class).to receive(:fetch_raw_feed).and_return(404) + + expect { described_class.call(feed) }.to change(feed, :status).to("red") + end + + it "outputs a message when things go wrong" do + feed = create(:feed) + allow(described_class).to receive(:fetch_raw_feed).and_return(404) + + expect { described_class.call(feed) } + .to invoke(:error).on(Rails.logger).with(/Something went wrong/) + end + end +end diff --git a/spec/commands/feed/find_new_stories_spec.rb b/spec/commands/feed/find_new_stories_spec.rb new file mode 100644 index 000000000..3cc645fad --- /dev/null +++ b/spec/commands/feed/find_new_stories_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.describe Feed::FindNewStories do + def create_entry(**options) + entry = { + published: nil, + title: "story1", + url: "www.story1.com", + content: "", + id: "www.story1.com", + **options + } + double(entry) + end + + def create_raw_feed(feed, entries: []) + xml = GenerateXml.call(feed, entries) + Feedjira.parse(xml) + end + + context "the feed contains no new stories" do + it "finds zero new stories" do + feed = create(:feed) + create(:story, entry_id: "www.story1.com", feed_id: feed.id) + raw_feed = create_raw_feed(feed, entries: [create_entry]) + result = described_class.call(raw_feed, feed.id) + + expect(result).to be_empty + end + end + + context "the feed contains new stories" do + it "returns stories that are not found in the database" do + feed = create(:feed) + create(:story, entry_id: "www.story1.com", feed_id: feed.id) + entry = create_entry(published: Time.zone.now, url: "www.story2.com") + raw_feed = create_raw_feed(feed, entries: [entry]) + + expect(described_class.call(raw_feed, feed.id)).to eq(raw_feed.entries) + end + end + + it "scans until matching the last story id" do + new_entry = create_entry(published: Time.zone.now, url: "www.new.com") + old_entry = create_entry(title: "old entry", url: "www.old.com") + raw_feed = create_raw_feed(create(:feed), entries: [new_entry, old_entry]) + result = described_class.call(raw_feed, 1, "www.old.com") + + expect(result).to eq([raw_feed.entries.first]) + end + + it "ignores stories older than 3 days" do + new_entry = create_entry(title: "new", published: 2.days.ago) + old_entry = create_entry(title: "old", published: 4.days.ago) + raw_feed = create_raw_feed(create(:feed), entries: [old_entry, new_entry]) + + expect(described_class.call(raw_feed, 1)) + .to eq(raw_feed.entries.filter { |entry| entry.title == "new" }) + end +end diff --git a/spec/commands/feed/import_from_opml_spec.rb b/spec/commands/feed/import_from_opml_spec.rb new file mode 100644 index 000000000..d5ab46139 --- /dev/null +++ b/spec/commands/feed/import_from_opml_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe Feed::ImportFromOpml do + tmw_football = { + name: "TMW Football Transfer News", + url: "http://www.transfermarketweb.com/rss" + } + + giant_robots = { + name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home", + url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots" + } + + autoblog = { name: "Autoblog", url: "http://feeds.autoblog.com/weblogsinc/autoblog/" } + city_guide = { name: "City Guide News", url: "http://www.probki.net/news/RSS_news_feed.asp" } + macrumors = { name: "MacRumors: Mac News and Rumors - Front Page", url: "http://feeds.macrumors.com/MacRumors-Front" } + dead_feed = { name: "http://deadfeed.example.com/feed.rss", url: "http://deadfeed.example.com/feed.rss" } + + def read_subscriptions + File.open( + File.expand_path("../../support/files/subscriptions.xml", __dir__) + ) + end + + def create_feed(feed_details) + create(:feed, feed_details) + end + + def find_feed(feed_details) + Feed.where(feed_details) + end + + before do + stub_request(:get, "http://feeds.macrumors.com/MacRumors-Front").to_return( + status: 200, + body: File.read("spec/sample_data/feeds/feed01_valid_feed/feed.xml") + ) + stub_request( + :get, + "http://deadfeed.example.com/feed.rss" + ).to_return(status: 404) + end + + context "adds title for existing feed" do + it "changes existing feed if name is nil" do + create_feed({ name: nil, url: macrumors[:url] }) + + described_class.call(read_subscriptions, user: default_user) + + expect(find_feed(macrumors)).to exist + end + + it "keeps existing feed name if name is something else" do + feed1 = create_feed({ name: "MacRumors", url: macrumors[:url] }) + + described_class.call(read_subscriptions, user: default_user) + + expect(find_feed(macrumors)).not_to exist + feed1.reload + expect(feed1.name).to eq("MacRumors") + end + end + + context "adding group_id for existing feeds" do + it "retains exising feeds" do + feed1 = create_feed(tmw_football) + feed2 = create_feed(giant_robots) + + described_class.call(read_subscriptions, user: default_user) + + expect(feed1).to be_valid + expect(feed2).to be_valid + end + + it "creates new groups" do + described_class.call(read_subscriptions, user: default_user) + + expect(Group.find_by(name: "Football News")).to be + expect(Group.find_by(name: "RoR")).to be + end + + it "sets group for existing feeds" do + feed1 = create_feed(tmw_football) + feed2 = create_feed(giant_robots) + + expect { described_class.call(read_subscriptions, user: default_user) } + .to change_record(feed1, :group_name).from(nil).to("Football News") + .and change_record(feed2, :group_name).from(nil).to("RoR") + end + end + + context "creates new feeds with groups" do + it "creates groups" do + described_class.call(read_subscriptions, user: default_user) + + expect(Group.find_by(name: "Football News")).to be + expect(Group.find_by(name: "RoR")).to be + end + + it "creates feeds" do + described_class.call(read_subscriptions, user: default_user) + + expect(find_feed(tmw_football)).to exist + expect(find_feed(giant_robots)).to exist + expect(find_feed(macrumors)).to exist + expect(find_feed(dead_feed)).to exist + end + + it "sets group" do + described_class.call(read_subscriptions, user: default_user) + + expect(find_feed(tmw_football).first.group) + .to eq(Group.find_by(name: "Football News")) + expect(find_feed(giant_robots).first.group) + .to eq(Group.find_by(name: "RoR")) + end + + it "does not create empty group" do + described_class.call(read_subscriptions, user: default_user) + + expect(Group.find_by(name: "Empty Group")).to be_nil + end + end + + context "creates new feeds without group" do + it "does not create any new group for feeds without group" do + described_class.call(read_subscriptions, user: default_user) + + group1 = Group.find_by(name: "Football News") + group2 = Group.find_by(name: "RoR") + + expect(Group.where.not(id: [group1.id, group2.id]).count).to eq(0) + end + + it "creates feeds without group_id" do + described_class.call(read_subscriptions, user: default_user) + + expect(find_feed(autoblog).first.group_id).to be_nil + expect(find_feed(city_guide).first.group_id).to be_nil + end + end +end diff --git a/spec/commands/feeds/add_new_feed_spec.rb b/spec/commands/feeds/add_new_feed_spec.rb deleted file mode 100644 index 3e113a6c8..000000000 --- a/spec/commands/feeds/add_new_feed_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require "spec_helper" - -app_require "/commands/feeds/add_new_feed" - -describe AddNewFeed do - describe "#add" do - context "feed cannot be discovered" do - let(:discoverer) { double(discover: false) } - it "returns false if cant discover any feeds" do - result = AddNewFeed.add("http://not-a-feed.com", discoverer) - - expect(result).to eq(false) - end - end - - context "feed can be discovered" do - let(:feed_url) { "http://feed.com/atom.xml" } - let(:feed_result) { double(title: feed.name, feed_url: feed.url) } - let(:discoverer) { double(discover: feed_result) } - let(:feed) { FeedFactory.build } - let(:repo) { double } - - it "parses and creates the feed if discovered" do - expect(repo).to receive(:create).and_return(feed) - - result = AddNewFeed.add("http://feed.com", discoverer, repo) - - expect(result).to be feed - end - end - end -end diff --git a/spec/commands/feeds/export_to_opml_spec.rb b/spec/commands/feeds/export_to_opml_spec.rb deleted file mode 100644 index cb4c231db..000000000 --- a/spec/commands/feeds/export_to_opml_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require "spec_helper" - -app_require "commands/feeds/export_to_opml" - -describe ExportToOpml do - describe "#to_xml" do - let(:feed_one) { FeedFactory.build } - let(:feed_two) { FeedFactory.build } - let(:feeds) { [feed_one, feed_two] } - - it "returns OPML XML" do - result = ExportToOpml.new(feeds).to_xml - - outlines = Nokogiri.XML(result).xpath("//body//outline") - expect(outlines.size).to eq(2) - expect(outlines.first["title"]).to eq feed_one.name - expect(outlines.first["xmlUrl"]).to eq feed_one.url - expect(outlines.last["title"]).to eq feed_two.name - expect(outlines.last["xmlUrl"]).to eq feed_two.url - end - - it "handles empty feeds" do - result = ExportToOpml.new([]).to_xml - - outlines = Nokogiri.XML(result).xpath("//body//outline") - expect(outlines).to be_empty - end - - it "has a proper title" do - result = ExportToOpml.new(feeds).to_xml - - title = Nokogiri.XML(result).xpath("//head//title").first - expect(title.content).to eq "Feeds from Stringer" - end - end -end diff --git a/spec/commands/feeds/import_from_opml_spec.rb b/spec/commands/feeds/import_from_opml_spec.rb deleted file mode 100644 index 2f691e9d2..000000000 --- a/spec/commands/feeds/import_from_opml_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -require "spec_helper" - -app_require "commands/feeds/import_from_opml" - -describe ImportFromOpml do - let(:subscriptions) { File.open(File.expand_path("../../../support/files/subscriptions.xml", __FILE__)) } - - def import - described_class.import(subscriptions) - end - - after do - Feed.delete_all - Group.delete_all - end - - let(:group_1) { Group.find_by_name("Football News") } - let(:group_2) { Group.find_by_name("RoR") } - - context "adding group_id for existing feeds" do - let!(:feed_1) do - Feed.create(name: "TMW Football Transfer News", url: "http://www.transfermarketweb.com/rss") - end - let!(:feed_2) do - Feed.create( - name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home", - url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots" - ) - end - before { import } - - it "retains exising feeds" do - expect(feed_1).to be_valid - expect(feed_2).to be_valid - end - - it "creates new groups" do - expect(group_1).to be - expect(group_2).to be - end - - it "sets group_id for existing feeds" do - expect(feed_1.reload.group).to eq group_1 - expect(feed_2.reload.group).to eq group_2 - end - end - - context "creates new feeds with groups" do - let(:feed_1) do - Feed.where(name: "TMW Football Transfer News", url: "http://www.transfermarketweb.com/rss") - end - let(:feed_2) do - Feed.where( - name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home", - url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots" - ) - end - before { import } - - it "creates groups" do - expect(group_1).to be - expect(group_1).to be - end - - it "creates feeds" do - expect(feed_1).to exist - expect(feed_2).to exist - end - - it "sets group" do - expect(feed_1.first.group).to eq group_1 - expect(feed_2.first.group).to eq group_2 - end - end - - context "creates new feeds without group" do - let(:feed_1) { Feed.where(name: "Autoblog", url: "http://feeds.autoblog.com/weblogsinc/autoblog/").first } - let(:feed_2) { Feed.where(name: "City Guide News", url: "http://www.probki.net/news/RSS_news_feed.asp").first } - - before { import } - - it "does not create any new group for feeds without group" do - expect(Group.where("id NOT IN (?)", [group_1.id, group_2.id]).count).to eq 0 - end - - it "creates feeds without group_id" do - expect(feed_1.group_id).to be_nil - expect(feed_2.group_id).to be_nil - end - end -end diff --git a/spec/commands/fever_api/authentication_spec.rb b/spec/commands/fever_api/authentication_spec.rb new file mode 100644 index 000000000..a46df29f7 --- /dev/null +++ b/spec/commands/fever_api/authentication_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::Authentication do + def authorization + Authorization.new(default_user) + end + + it "returns the latest feed's last_fetched time" do + feed = create(:feed, last_fetched: 1.month.ago) + create(:feed, last_fetched: 1.year.ago) + + result = described_class.call(authorization:) + expect(result[:last_refreshed_on_time]).to eq(Integer(feed.last_fetched)) + end + + it "returns 0 for last_refreshed_on_time when there are no feeds" do + result = described_class.call(authorization:) + expect(result[:last_refreshed_on_time]).to eq(0) + end + + it "returns a hash with keys :auth and :last_refreshed_on_time" do + result = described_class.call(authorization:) + expect(result).to eq(auth: 1, last_refreshed_on_time: 0) + end +end diff --git a/spec/commands/fever_api/read_favicons_spec.rb b/spec/commands/fever_api/read_favicons_spec.rb new file mode 100644 index 000000000..982123ef8 --- /dev/null +++ b/spec/commands/fever_api/read_favicons_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadFavicons do + it "returns a fixed icon list if requested" do + expect(described_class.call({ favicons: nil })).to eq( + favicons: [ + { + id: 0, + data: "image/gif;base64,#{described_class::ICON}" + } + ] + ) + end + + it "returns an empty hash otherwise" do + expect(described_class.call({})).to eq({}) + end +end diff --git a/spec/commands/fever_api/read_feeds_groups_spec.rb b/spec/commands/fever_api/read_feeds_groups_spec.rb new file mode 100644 index 000000000..6d8c75454 --- /dev/null +++ b/spec/commands/fever_api/read_feeds_groups_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadFeedsGroups do + it "returns a list of groups requested through feeds" do + group = create(:group) + feeds = create_list(:feed, 3, group:) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, feeds: nil)).to eq( + feeds_groups: [ + { + group_id: group.id, + feed_ids: feeds.map(&:id).join(",") + } + ] + ) + end + + it "returns a list of groups requested through groups" do + group = create(:group) + feeds = create_list(:feed, 3, group:) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, groups: nil)).to eq( + feeds_groups: [ + { + group_id: group.id, + feed_ids: feeds.map(&:id).join(",") + } + ] + ) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/read_feeds_spec.rb b/spec/commands/fever_api/read_feeds_spec.rb new file mode 100644 index 000000000..defbc2cd5 --- /dev/null +++ b/spec/commands/fever_api/read_feeds_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadFeeds do + it "returns a list of feeds" do + feeds = create_list(:feed, 3) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, feeds: nil)) + .to eq(feeds: feeds.map(&:as_fever_json)) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/read_groups_spec.rb b/spec/commands/fever_api/read_groups_spec.rb new file mode 100644 index 000000000..11664f749 --- /dev/null +++ b/spec/commands/fever_api/read_groups_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadGroups do + it "returns a group list if requested" do + groups = create_pair(:group) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, groups: nil)) + .to eq(groups: [Group::UNGROUPED, *groups].map(&:as_fever_json)) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/read_items_spec.rb b/spec/commands/fever_api/read_items_spec.rb new file mode 100644 index 000000000..93a998c68 --- /dev/null +++ b/spec/commands/fever_api/read_items_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadItems do + it "returns a list of unread items including total count" do + stories = create_list(:story, 3) + authorization = Authorization.new(default_user) + + items = stories.map(&:as_fever_json) + expect(described_class.call(authorization:, items: nil)) + .to eq(items:, total_items: 3) + end + + it "returns a list of unread items with empty since_id" do + stories = create_list(:story, 3) + authorization = Authorization.new(default_user) + + items = stories.map(&:as_fever_json) + expect(described_class.call(authorization:, items: nil, since_id: "")) + .to eq(items:, total_items: 3) + end + + it "returns a list of unread items since id including total count" do + story, *other_stories = create_list(:story, 3) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, items: nil, since_id: story.id)) + .to eq(items: other_stories.map(&:as_fever_json), total_items: 3) + end + + it "returns a list of specified items including total count" do + _story1, *other_stories = create_list(:story, 3) + with_ids = other_stories.map(&:id) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, items: nil, with_ids:)).to eq( + items: other_stories.reverse.map(&:as_fever_json), + total_items: 2 + ) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/read_links_spec.rb b/spec/commands/fever_api/read_links_spec.rb new file mode 100644 index 000000000..375ca997a --- /dev/null +++ b/spec/commands/fever_api/read_links_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::ReadLinks do + it "returns a fixed link list if requested" do + expect(described_class.call(links: nil)).to eq(links: []) + end + + it "returns an empty hash otherwise" do + expect(described_class.call({})).to eq({}) + end +end diff --git a/spec/commands/fever_api/sync_saved_item_ids_spec.rb b/spec/commands/fever_api/sync_saved_item_ids_spec.rb new file mode 100644 index 000000000..03abff2ff --- /dev/null +++ b/spec/commands/fever_api/sync_saved_item_ids_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::SyncSavedItemIds do + it "returns a list of starred items if requested" do + stories = create_list(:story, 3, :starred) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, saved_item_ids: nil)) + .to eq(saved_item_ids: stories.map(&:id).join(",")) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/sync_unread_item_ids_spec.rb b/spec/commands/fever_api/sync_unread_item_ids_spec.rb new file mode 100644 index 000000000..0155cec68 --- /dev/null +++ b/spec/commands/fever_api/sync_unread_item_ids_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::SyncUnreadItemIds do + it "returns a list of unread items if requested" do + stories = create_list(:story, 3) + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:, unread_item_ids: nil)) + .to eq(unread_item_ids: stories.map(&:id).join(",")) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/write_mark_feed_spec.rb b/spec/commands/fever_api/write_mark_feed_spec.rb new file mode 100644 index 000000000..466072e98 --- /dev/null +++ b/spec/commands/fever_api/write_mark_feed_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::WriteMarkFeed do + def params(feed, before:) + authorization = Authorization.new(feed.user) + + { authorization:, mark: "feed", id: feed.id, before: } + end + + it "marks the feed stories as read before the given timestamp" do + feed = create(:feed) + story = create(:story, feed:, created_at: 1.week.ago) + + expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) } + .to change { story.reload.is_read? }.from(false).to(true) + end + + it "does not mark the feed stories as read after the given timestamp" do + feed = create(:feed) + story = create(:story, feed:) + + expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) } + .to not_change { story.reload.is_read? }.from(false) + end + + it "returns an empty hash otherwise" do + authorization = Authorization.new(default_user) + + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/write_mark_group_spec.rb b/spec/commands/fever_api/write_mark_group_spec.rb new file mode 100644 index 000000000..1d4770d26 --- /dev/null +++ b/spec/commands/fever_api/write_mark_group_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::WriteMarkGroup do + def authorization + Authorization.new(default_user) + end + + it "marks the group stories as read before the given timestamp" do + story = create(:story, :with_group, created_at: 1.week.ago) + before = 1.day.ago + id = story.group_id + + expect { described_class.call(authorization:, mark: "group", id:, before:) } + .to change_record(story, :is_read).from(false).to(true) + end + + it "returns an empty hash otherwise" do + expect(described_class.call(authorization:)).to eq({}) + end +end diff --git a/spec/commands/fever_api/write_mark_item_spec.rb b/spec/commands/fever_api/write_mark_item_spec.rb new file mode 100644 index 000000000..4c52b27aa --- /dev/null +++ b/spec/commands/fever_api/write_mark_item_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.describe FeverAPI::WriteMarkItem do + context "when as: 'read'" do + it "marks the story as read" do + story = create(:story) + authorization = Authorization.new(story.user) + params = { authorization:, mark: "item", as: "read", id: story.id } + + expect { subject.call(**params) } + .to change_record(story, :is_read).from(false).to(true) + end + end + + context "when as: 'unread'" do + it "marks the story as unread" do + story = create(:story, :read) + authorization = Authorization.new(story.user) + params = { authorization:, mark: "item", as: "unread", id: story.id } + + expect { subject.call(**params) } + .to change_record(story, :is_read).from(true).to(false) + end + end + + context "when as: 'saved'" do + it "marks the story as starred" do + story = create(:story) + authorization = Authorization.new(story.user) + params = { authorization:, mark: "item", as: "saved", id: story.id } + + expect { subject.call(**params) } + .to change_record(story, :is_starred).from(false).to(true) + end + end + + context "when as: 'unsaved'" do + it "marks the story as unstarred" do + story = create(:story, :starred) + authorization = Authorization.new(story.user) + params = { authorization:, mark: "item", as: "unsaved", id: story.id } + + expect { subject.call(**params) } + .to change_record(story, :is_starred).from(true).to(false) + end + end + + it "returns an empty hash when :as is not present" do + authorization = Authorization.new(default_user) + + expect(subject.call(authorization:, mark: "item")).to eq({}) + end +end diff --git a/spec/commands/find_new_stories_spec.rb b/spec/commands/find_new_stories_spec.rb deleted file mode 100644 index 41cce2054..000000000 --- a/spec/commands/find_new_stories_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -require "spec_helper" - -app_require "repositories/story_repository" -app_require "commands/feeds/find_new_stories" - -describe FindNewStories do - describe "#new_stories" do - context "the feed contains no new stories" do - before do - allow(StoryRepository).to receive(:exists?).and_return(true) - end - - it "should find zero new stories" do - story1 = double(published: nil, id: "story1") - story2 = double(published: nil, id: "story2") - feed = double(entries: [story1, story2]) - - result = FindNewStories.new(feed, 1, Time.new(2013, 1, 2)).new_stories - expect(result).to be_empty - end - end - - context "the feed contains new stories" do - it "should return stories that are not found in the database" do - story1 = double(published: nil, id: "story1") - story2 = double(published: nil, id: "story2") - feed = double(entries: [story1, story2]) - - allow(StoryRepository).to receive(:exists?).with("story1", 1).and_return(true) - allow(StoryRepository).to receive(:exists?).with("story2", 1).and_return(false) - - result = FindNewStories.new(feed, 1, Time.new(2013, 1, 2)).new_stories - expect(result).to eq [story2] - end - end - - it "should scan until matching the last story id" do - new_story = double(published: nil, id: "new-story") - old_story = double(published: nil, id: "old-story") - feed = double(last_modified: nil, entries: [new_story, old_story]) - - result = FindNewStories.new(feed, 1, Time.new(2013, 1, 3), "old-story").new_stories - expect(result).to eq [new_story] - end - - it "should ignore stories older than 3 days" do - new_stories = [ - double(published: 1.hour.ago, id: "new-story"), - double(published: 2.days.ago, id: "new-story") - ] - - stories_older_than_3_days = [ - double(published: 3.days.ago, id: "new-story"), - double(published: 4.days.ago, id: "new-story") - ] - - feed = double(last_modified: nil, entries: new_stories + stories_older_than_3_days) - - result = FindNewStories.new(feed, 1, nil, nil).new_stories - expect(result).not_to include(stories_older_than_3_days) - end - end -end diff --git a/spec/commands/stories/mark_all_as_read_spec.rb b/spec/commands/stories/mark_all_as_read_spec.rb deleted file mode 100644 index b72c75b78..000000000 --- a/spec/commands/stories/mark_all_as_read_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_all_as_read" - -describe MarkAllAsRead do - describe "#mark_as_read" do - let(:stories) { double } - let(:repo) { double(fetch_by_ids: stories) } - - it "marks all stories as read" do - command = MarkAllAsRead.new([1, 2], repo) - expect(stories).to receive(:update_all).with(is_read: true) - command.mark_as_read - end - end -end diff --git a/spec/commands/stories/mark_as_read_spec.rb b/spec/commands/stories/mark_as_read_spec.rb deleted file mode 100644 index b24ff9d74..000000000 --- a/spec/commands/stories/mark_as_read_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_as_read" - -describe MarkAsRead do - describe "#mark_as_read" do - let(:story) { double } - let(:repo) { double(fetch: story) } - - it "marks a story as read" do - command = MarkAsRead.new(1, repo) - expect(story).to receive(:update_attributes).with(is_read: true) - command.mark_as_read - end - end -end diff --git a/spec/commands/stories/mark_as_starred.rb b/spec/commands/stories/mark_as_starred.rb deleted file mode 100644 index ecfca860a..000000000 --- a/spec/commands/stories/mark_as_starred.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_as_starred" - -describe MarkAsStarred do - describe "#mark_as_starred" do - let(:story) { double } - let(:repo) { double(fetch: story) } - - it "marks a story as starred" do - command = MarkAsStarred.new(1, repo) - expect(story).to receive(:update_attributes).with(is_starred: true) - command.mark_as_starred - end - end -end diff --git a/spec/commands/stories/mark_as_unread_spec.rb b/spec/commands/stories/mark_as_unread_spec.rb deleted file mode 100644 index 51716ed01..000000000 --- a/spec/commands/stories/mark_as_unread_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_as_unread" - -describe MarkAsUnread do - describe "#mark_as_unread" do - let(:story) { double } - let(:repo) { double(fetch: story) } - - it "marks a story as unread" do - command = MarkAsUnread.new(1, repo) - expect(story).to receive(:update_attributes).with(is_read: false) - command.mark_as_unread - end - end -end diff --git a/spec/commands/stories/mark_as_unstarred_spec.rb b/spec/commands/stories/mark_as_unstarred_spec.rb deleted file mode 100644 index d55d94418..000000000 --- a/spec/commands/stories/mark_as_unstarred_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_as_unstarred" - -describe MarkAsUnstarred do - describe "#mark_as_unstarred" do - let(:story) { double } - let(:repo) { double(fetch: story) } - - it "marks a story as unstarred" do - command = MarkAsUnstarred.new(1, repo) - expect(story).to receive(:update_attributes).with(is_starred: false) - command.mark_as_unstarred - end - end -end diff --git a/spec/commands/stories/mark_feed_as_read_spec.rb b/spec/commands/stories/mark_feed_as_read_spec.rb deleted file mode 100644 index 00e5dff69..000000000 --- a/spec/commands/stories/mark_feed_as_read_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_feed_as_read" - -describe MarkFeedAsRead do - describe "#mark_feed_as_read" do - let(:stories) { double } - let(:repo) { double(fetch_unread_for_feed_by_timestamp: stories) } - - it "marks feed 1 as read" do - command = MarkFeedAsRead.new(1, Time.now.to_i, repo) - expect(stories).to receive(:update_all).with(is_read: true) - command.mark_feed_as_read - end - end -end diff --git a/spec/commands/stories/mark_group_as_read_spec.rb b/spec/commands/stories/mark_group_as_read_spec.rb deleted file mode 100644 index fcbbfc702..000000000 --- a/spec/commands/stories/mark_group_as_read_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -require "spec_helper" - -app_require "commands/stories/mark_group_as_read" - -describe MarkGroupAsRead do - describe '#mark_group_as_read' do - let(:stories) { double } - let(:repo) { double } - let(:timestamp) { Time.now.to_i } - - def run_command(group_id) - MarkGroupAsRead.new(group_id, timestamp, repo) - end - - it "marks group as read" do - command = run_command(2) - expect(stories).to receive(:update_all).with(is_read: true) - expect(repo).to receive(:fetch_unread_by_timestamp_and_group).with(timestamp, 2).and_return(stories) - command.mark_group_as_read - end - - it "does not mark any group as read when group is not provided" do - command = run_command(nil) - expect(repo).not_to receive(:fetch_unread_by_timestamp_and_group) - expect(repo).not_to receive(:fetch_unread_by_timestamp) - command.mark_group_as_read - end - - context "SPARKS_GROUP_ID and KINDLING_GROUP_ID" do - before do - expect(stories).to receive(:update_all).with(is_read: true) - expect(repo).to receive(:fetch_unread_by_timestamp).and_return(stories) - end - - it "marks as read all feeds when group is 0" do - command = run_command(0) - command.mark_group_as_read - end - - it "marks as read all feeds when group is -1" do - command = run_command(-1) - command.mark_group_as_read - end - end - end -end diff --git a/spec/commands/story/mark_all_as_read_spec.rb b/spec/commands/story/mark_all_as_read_spec.rb new file mode 100644 index 000000000..f30eea330 --- /dev/null +++ b/spec/commands/story/mark_all_as_read_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe MarkAllAsRead do + it "marks all stories as read" do + stories = create_pair(:story) + + expect { described_class.call(stories.map(&:id)) } + .to change_all_records(stories, :is_read).from(false).to(true) + end +end diff --git a/spec/commands/story/mark_as_read_spec.rb b/spec/commands/story/mark_as_read_spec.rb new file mode 100644 index 000000000..aa0a7630c --- /dev/null +++ b/spec/commands/story/mark_as_read_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe MarkAsRead do + it "marks a story as read" do + story = create(:story, is_read: false) + + expect { described_class.call(story.id) } + .to change_record(story, :is_read).from(false).to(true) + end +end diff --git a/spec/commands/story/mark_as_starred_spec.rb b/spec/commands/story/mark_as_starred_spec.rb new file mode 100644 index 000000000..45c811301 --- /dev/null +++ b/spec/commands/story/mark_as_starred_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe MarkAsStarred do + describe "#mark_as_starred" do + it "marks a story as starred" do + story = create(:story) + + expect { described_class.call(story.id) } + .to change_record(story, :is_starred).from(false).to(true) + end + end +end diff --git a/spec/commands/story/mark_as_unread_spec.rb b/spec/commands/story/mark_as_unread_spec.rb new file mode 100644 index 000000000..c42fbf5af --- /dev/null +++ b/spec/commands/story/mark_as_unread_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe MarkAsUnread do + it "marks a story as unread" do + story = create(:story, is_read: true) + + expect { described_class.call(story.id) } + .to change_record(story, :is_read).from(true).to(false) + end +end diff --git a/spec/commands/story/mark_as_unstarred_spec.rb b/spec/commands/story/mark_as_unstarred_spec.rb new file mode 100644 index 000000000..86d4e0104 --- /dev/null +++ b/spec/commands/story/mark_as_unstarred_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe MarkAsUnstarred do + it "marks a story as unstarred" do + story = create(:story, :starred) + + expect { described_class.call(story.id) } + .to change_record(story, :is_starred).from(true).to(false) + end +end diff --git a/spec/commands/story/mark_feed_as_read_spec.rb b/spec/commands/story/mark_feed_as_read_spec.rb new file mode 100644 index 000000000..87d5aa43f --- /dev/null +++ b/spec/commands/story/mark_feed_as_read_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe MarkFeedAsRead do + it "marks feed stories as read before timestamp" do + story = create(:story, created_at: 1.week.ago) + before = 1.day.ago + + expect { described_class.call(story.feed_id, before) } + .to change_record(story, :is_read).from(false).to(true) + end +end diff --git a/spec/commands/story/mark_group_as_read_spec.rb b/spec/commands/story/mark_group_as_read_spec.rb new file mode 100644 index 000000000..2204b3438 --- /dev/null +++ b/spec/commands/story/mark_group_as_read_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe MarkGroupAsRead do + describe "#mark_group_as_read" do + it "marks group as read" do + story = create(:story, :with_group, created_at: 1.week.ago) + timestamp = 1.day.ago + + expect { described_class.call(story.group_id, timestamp) } + .to change_record(story, :is_read).from(false).to(true) + end + + it "does not mark any group as read when group is not provided" do + story = create(:story, :with_group, created_at: 1.week.ago) + timestamp = 1.day.ago + + expect { described_class.call(nil, timestamp) } + .not_to change_record(story, :is_read).from(false) + end + + it "marks as read all feeds when group is 0 (KINDLING_GROUP_ID)" do + story = create(:story, :with_group, created_at: 1.week.ago) + timestamp = 1.day.ago + + expect { described_class.call(0, timestamp) } + .to change_record(story, :is_read).from(false).to(true) + end + + it "marks as read all feeds when group is -1 (SPARKS_GROUP_ID)" do + story = create(:story, :with_group, created_at: 1.week.ago) + timestamp = 1.day.ago + + expect { described_class.call(-1, timestamp) } + .to change_record(story, :is_read).from(false).to(true) + end + end +end diff --git a/spec/commands/user/sign_in_user_spec.rb b/spec/commands/user/sign_in_user_spec.rb new file mode 100644 index 000000000..47908db47 --- /dev/null +++ b/spec/commands/user/sign_in_user_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe SignInUser do + it "returns the user if the password is valid" do + result = described_class.call(default_user.username, default_user.password) + + expect(result).to eq(default_user) + end + + it "returns nil if password is invalid" do + create(:user) + + result = described_class.call(default_user.username, "not-the-pw") + + expect(result).to be_nil + end + + it "returns nil if the user does not exist" do + result = described_class.call("not-the-username", "not-the-pw") + + expect(result).to be_nil + end +end diff --git a/spec/commands/users/change_user_password_spec.rb b/spec/commands/users/change_user_password_spec.rb deleted file mode 100644 index caae4e3ee..000000000 --- a/spec/commands/users/change_user_password_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "spec_helper" -require "support/active_record" - -app_require "commands/users/change_user_password" - -describe ChangeUserPassword do - let(:repo) { double } - let(:user) { User.create(password: old_password) } - - let(:old_password) { "old-pw" } - let(:new_password) { "new-pw" } - - describe "#change_user_password" do - it "changes the password of the user" do - expect(repo).to receive(:first).and_return(user) - expect(repo).to receive(:save) - - command = ChangeUserPassword.new(repo) - result = command.change_user_password(new_password) - - expect(BCrypt::Password.new(result.password_digest)).to eq new_password - end - - it "changes the API key of the user" do - expect(repo).to receive(:first).and_return(user) - expect(repo).to receive(:save) - - command = ChangeUserPassword.new(repo) - result = command.change_user_password(new_password) - - expect(result.api_key).to eq ApiKey.compute(new_password) - end - end -end diff --git a/spec/commands/users/complete_setup_spec.rb b/spec/commands/users/complete_setup_spec.rb deleted file mode 100644 index df34301c3..000000000 --- a/spec/commands/users/complete_setup_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -require "spec_helper" - -app_require "commands/users/complete_setup" - -describe CompleteSetup do - let(:user) { UserFactory.build } - it "marks setup as complete" do - expect(user).to receive(:save).once - - result = CompleteSetup.complete(user) - expect(result.setup_complete).to eq(true) - end -end diff --git a/spec/commands/users/create_user_spec.rb b/spec/commands/users/create_user_spec.rb deleted file mode 100644 index ffbcc5133..000000000 --- a/spec/commands/users/create_user_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "spec_helper" - -app_require "commands/users/create_user" - -describe CreateUser do - let(:repo) { double } - - describe "#create" do - it "remove any existing users and create a user with the password supplied" do - command = CreateUser.new(repo) - - expect(repo).to receive(:create) - expect(repo).to receive(:delete_all) - - command.create("password") - end - end -end diff --git a/spec/commands/users/sign_in_user_spec.rb b/spec/commands/users/sign_in_user_spec.rb deleted file mode 100644 index f3f921810..000000000 --- a/spec/commands/users/sign_in_user_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require "spec_helper" - -app_require "commands/users/sign_in_user" - -describe SignInUser do - let(:valid_password) { "valid-pw" } - let(:repo) { double(first: user) } - - let(:user) do - double(password_digest: BCrypt::Password.create(valid_password), id: 1) - end - - describe "#sign_in" do - it "returns the user if the password is valid" do - result = SignInUser.sign_in(valid_password, repo) - - expect(result.id).to eq 1 - end - - it "returns nil if password is invalid" do - result = SignInUser.sign_in("not-the-pw", repo) - - expect(result).to be_nil - end - end -end diff --git a/spec/controllers/debug_controller_spec.rb b/spec/controllers/debug_controller_spec.rb deleted file mode 100644 index f4e1a1922..000000000 --- a/spec/controllers/debug_controller_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -require "spec_helper" -require "support/active_record" - -app_require "controllers/debug_controller" - -describe "DebugController" do - describe "GET /debug" do - before do - delayed_job = double "Delayed::Job" - allow(delayed_job).to receive(:count).and_return(42) - stub_const("Delayed::Job", delayed_job) - - migration_status_instance = double "migration_status_instance" - allow(migration_status_instance).to receive(:pending_migrations).and_return ["Migration B - 2", "Migration C - 3"] - migration_status = double "MigrationStatus" - allow(migration_status).to receive(:new).and_return(migration_status_instance) - stub_const("MigrationStatus", migration_status) - end - - it "displays the current Ruby version" do - get "/debug" - - page = last_response.body - expect(page).to have_tag("dd", text: /#{RUBY_VERSION}/) - end - - it "displays the user agent" do - get "/debug", {}, "HTTP_USER_AGENT" => "test" - - page = last_response.body - expect(page).to have_tag("dd", text: /test/) - end - - it "displays the delayed job count" do - get "/debug" - - page = last_response.body - expect(page).to have_tag("dd", text: /42/) - end - - it "displays pending migrations" do - get "/debug" - - page = last_response.body - expect(page).to have_tag("li", text: /Migration B - 2/) - expect(page).to have_tag("li", text: /Migration C - 3/) - end - end -end diff --git a/spec/controllers/feeds_controller_spec.rb b/spec/controllers/feeds_controller_spec.rb deleted file mode 100644 index 90c8c3d3d..000000000 --- a/spec/controllers/feeds_controller_spec.rb +++ /dev/null @@ -1,162 +0,0 @@ -require "spec_helper" - -app_require "controllers/feeds_controller" - -describe "FeedsController" do - let(:feeds) { [FeedFactory.build, FeedFactory.build] } - - describe "GET /feeds" do - it "renders a list of feeds" do - expect(FeedRepository).to receive(:list).and_return(feeds) - - get "/feeds" - - page = last_response.body - expect(page).to have_tag("ul#feed-list") - expect(page).to have_tag("li.feed", count: 2) - end - - it "displays message to add feeds if there are none" do - expect(FeedRepository).to receive(:list).and_return([]) - - get "/feeds" - - page = last_response.body - expect(page).to have_tag("#add-some-feeds") - end - end - - describe "GET /feeds/:feed_id/edit" do - it "fetches a feed given the id" do - feed = Feed.new(name: "Rainbows and unicorns", url: "example.com/feed") - expect(FeedRepository).to receive(:fetch).with("123").and_return(feed) - - get "/feeds/123/edit" - - expect(last_response.body).to include("Rainbows and unicorns") - expect(last_response.body).to include("example.com/feed") - end - end - - describe "PUT /feeds/:feed_id" do - it "updates a feed given the id" do - feed = FeedFactory.build(url: "example.com/atom") - expect(FeedRepository).to receive(:fetch).with("123").and_return(feed) - expect(FeedRepository).to receive(:update_feed).with(feed, "Test", "example.com/feed", nil) - - put "/feeds/123", feed_id: "123", feed_name: "Test", feed_url: "example.com/feed" - - expect(last_response).to be_redirect - end - - it "updates a feed group given the id" do - feed = FeedFactory.build(url: "example.com/atom") - expect(FeedRepository).to receive(:fetch).with("123").and_return(feed) - expect(FeedRepository).to receive(:update_feed).with(feed, feed.name, feed.url, "321") - - put "/feeds/123", feed_id: "123", feed_name: feed.name, feed_url: feed.url, group_id: "321" - - expect(last_response).to be_redirect - end - end - - describe "DELETE /feeds/:feed_id" do - it "deletes a feed given the id" do - expect(FeedRepository).to receive(:delete).with("123") - - delete "/feeds/123" - end - end - - describe "GET /feeds/new" do - it "displays a form and submit button" do - get "/feeds/new" - - page = last_response.body - expect(page).to have_tag("form#add-feed-setup") - expect(page).to have_tag("input#submit") - end - end - - describe "POST /feeds" do - context "when the feed url is valid" do - let(:feed_url) { "http://example.com/" } - let(:valid_feed) { double(valid?: true) } - - it "adds the feed and queues it to be fetched" do - expect(AddNewFeed).to receive(:add).with(feed_url).and_return(valid_feed) - expect(FetchFeeds).to receive(:enqueue).with([valid_feed]) - - post "/feeds", feed_url: feed_url - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/" - end - end - - context "when the feed url is invalid" do - let(:feed_url) { "http://not-a-valid-feed.com/" } - - it "adds the feed and queues it to be fetched" do - expect(AddNewFeed).to receive(:add).with(feed_url).and_return(false) - - post "/feeds", feed_url: feed_url - - page = last_response.body - expect(page).to have_tag(".error") - end - end - - context "when the feed url is one we already subscribe to" do - let(:feed_url) { "http://example.com/" } - let(:invalid_feed) { double(valid?: false) } - - it "adds the feed and queues it to be fetched" do - expect(AddNewFeed).to receive(:add).with(feed_url).and_return(invalid_feed) - - post "/feeds", feed_url: feed_url - - page = last_response.body - expect(page).to have_tag(".error") - end - end - end - - describe "GET /feeds/import" do - it "displays the import options" do - get "/feeds/import" - - page = last_response.body - expect(page).to have_tag("input#opml_file") - expect(page).to have_tag("a#skip") - end - end - - describe "POST /feeds/import" do - let(:opml_file) { Rack::Test::UploadedFile.new("spec/sample_data/subscriptions.xml", "application/xml") } - - it "parse OPML and starts fetching" do - expect(ImportFromOpml).to receive(:import).once - - post "/feeds/import", "opml_file" => opml_file - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/setup/tutorial" - end - end - - describe "GET /feeds/export" do - let(:some_xml) { "some dummy opml" } - before { allow(Feed).to receive(:all) } - - it "returns an OPML file" do - expect_any_instance_of(ExportToOpml).to receive(:to_xml).and_return(some_xml) - - get "/feeds/export" - - expect(last_response.body).to eq some_xml - expect(last_response.header["Content-Type"]).to include "application/xml" - expect(last_response.header["Content-Disposition"]).to eq("attachment; filename=\"stringer.opml\"") - end - end -end diff --git a/spec/controllers/first_run_controller_spec.rb b/spec/controllers/first_run_controller_spec.rb deleted file mode 100644 index 22155e133..000000000 --- a/spec/controllers/first_run_controller_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -require "spec_helper" - -app_require "controllers/first_run_controller" - -describe "FirstRunController" do - context "when a user has not been setup" do - before do - allow(UserRepository).to receive(:setup_complete?).and_return(false) - end - - describe "GET /setup/password" do - it "displays a form to enter your password" do - get "/setup/password" - - page = last_response.body - expect(page).to have_tag("form#password_setup") - expect(page).to have_tag("input#password") - expect(page).to have_tag("input#password-confirmation") - expect(page).to have_tag("input#submit") - end - end - - describe "POST /setup/password" do - it "rejects empty passwords" do - post "/setup/password" - - page = last_response.body - expect(page).to have_tag("div.error") - end - - it "rejects when password isn't confirmed" do - post "/setup/password", password: "foo", password_confirmation: "bar" - - page = last_response.body - expect(page).to have_tag("div.error") - end - - it "accepts confirmed passwords and redirects to next step" do - expect_any_instance_of(CreateUser).to receive(:create).with("foo").and_return(double(id: 1)) - - post "/setup/password", password: "foo", password_confirmation: "foo" - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/feeds/import" - end - end - - describe "GET /setup/tutorial" do - let(:user) { double } - let(:feeds) { [double, double] } - - before do - allow(UserRepository).to receive(:fetch).and_return(user) - allow(Feed).to receive(:all).and_return(feeds) - end - - it "displays the tutorial and completes setup" do - expect(CompleteSetup).to receive(:complete).with(user).once - expect(FetchFeeds).to receive(:enqueue).with(feeds).once - - get "/setup/tutorial" - - page = last_response.body - expect(page).to have_tag("#mark-all-instruction") - expect(page).to have_tag("#refresh-instruction") - expect(page).to have_tag("#feeds-instruction") - expect(page).to have_tag("#add-feed-instruction") - expect(page).to have_tag("#story-instruction") - expect(page).to have_tag("#start") - end - end - end - - context "when a user has been setup" do - before do - allow(UserRepository).to receive(:setup_complete?).and_return(true) - end - - it "should redirect any requests to first run stuff" do - get "/" - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/news" - - get "/setup/password" - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/news" - - get "/setup/tutorial" - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/news" - end - end -end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb deleted file mode 100644 index cb67be117..000000000 --- a/spec/controllers/sessions_controller_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "spec_helper" - -app_require "controllers/sessions_controller" - -describe "SessionsController" do - describe "GET /login" do - it "has a password input and login button" do - get "/login" - - page = last_response.body - expect(page).to have_tag("input#password") - expect(page).to have_tag("#login") - end - end - - describe "POST /login" do - it "denies access when password is incorrect" do - allow(SignInUser).to receive(:sign_in).and_return(nil) - - post "/login", password: "not-the-password" - - page = last_response.body - expect(page).to have_tag(".error") - end - - it "allows access when password is correct" do - allow(SignInUser).to receive(:sign_in).and_return(double(id: 1)) - - post "/login", password: "the-password" - - expect(session[:user_id]).to eq 1 - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/" - end - - it "redirects to the previous path when present" do - allow(SignInUser).to receive(:sign_in).and_return(double(id: 1)) - - post "/login", { password: "the-password" }, - "rack.session" => { redirect_to: "/archive" } - - expect(session[:redirect_to]).to be_nil - expect(URI.parse(last_response.location).path).to eq "/archive" - end - end - - describe "GET /logout" do - it "clears the session and redirects" do - get "/logout", {}, "rack.session" => { userid: 1 } - - expect(session[:user_id]).to be_nil - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/" - end - end -end diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb deleted file mode 100644 index d0ebe15d9..000000000 --- a/spec/controllers/stories_controller_spec.rb +++ /dev/null @@ -1,191 +0,0 @@ -require "spec_helper" -require "will_paginate/array" - -app_require "controllers/stories_controller" - -describe "StoriesController" do - let(:story_one) { StoryFactory.build } - let(:story_two) { StoryFactory.build } - let(:stories) { [story_one, story_two] } - - describe "GET /news" do - before do - allow(StoryRepository).to receive(:unread).and_return(stories) - allow(UserRepository).to receive(:fetch).and_return(double) - end - - it "display list of unread stories" do - get "/news" - - expect(last_response.body).to have_tag("#stories") - end - - it "displays the blog title and article title" do - expect(StoryRepository).to receive(:unread).and_return([story_one]) - - get "/news" - - expect(last_response.body).to include(story_one.headline) - expect(last_response.body).to include(story_one.source) - end - - it "displays all user actions" do - get "/news" - - expect(last_response.body).to have_tag("#mark-all") - expect(last_response.body).to have_tag("#refresh") - expect(last_response.body).to have_tag("#feeds") - expect(last_response.body).to have_tag("#add-feed") - end - - it "should have correct footer links" do - get "/news" - - page = last_response.body - expect(page).to have_tag("a", with: { href: "/feeds/export" }) - expect(page).to have_tag("a", with: { href: "/logout" }) - expect(page).to have_tag("a", with: { href: "https://github.com/swanson/stringer" }) - end - - it "displays a zen-like message when there are no unread stories" do - allow(StoryRepository).to receive(:unread).and_return([]) - - get "/news" - - expect(last_response.body).to have_tag("#zen") - end - end - - describe "GET /archive" do - let(:read_one) { StoryFactory.build(is_read: true) } - let(:read_two) { StoryFactory.build(is_read: true) } - let(:stories) { [read_one, read_two].paginate } - before { allow(StoryRepository).to receive(:read).and_return(stories) } - - it "displays the list of read stories with pagination" do - get "/archive" - - page = last_response.body - expect(page).to have_tag("#stories") - expect(page).to have_tag("div#pagination") - end - end - - describe "GET /starred" do - let(:starred_one) { StoryFactory.build(is_starred: true) } - let(:starred_two) { StoryFactory.build(is_starred: true) } - let(:stories) { [starred_one, starred_two].paginate } - before { allow(StoryRepository).to receive(:starred).and_return(stories) } - - it "displays the list of starred stories with pagination" do - get "/starred" - - page = last_response.body - expect(page).to have_tag("#stories") - expect(page).to have_tag("div#pagination") - end - end - - describe "PUT /stories/:id" do - before { allow(StoryRepository).to receive(:fetch).and_return(story_one) } - context "is_read parameter" do - context "when it is not malformed" do - it "marks a story as read" do - expect(StoryRepository).to receive(:save).once - - put "/stories/#{story_one.id}", { is_read: true }.to_json - - expect(story_one.is_read).to eq true - end - end - - context "when it is malformed" do - it "marks a story as read" do - expect(StoryRepository).to receive(:save).once - - put "/stories/#{story_one.id}", { is_read: "malformed" }.to_json - - expect(story_one.is_read).to eq true - end - end - end - - context "keep_unread parameter" do - context "when it is not malformed" do - it "marks a story as permanently unread" do - put "/stories/#{story_one.id}", { keep_unread: false }.to_json - - expect(story_one.keep_unread).to eq false - end - end - - context "when it is malformed" do - it "marks a story as permanently unread" do - put "/stories/#{story_one.id}", { keep_unread: "malformed" }.to_json - - expect(story_one.keep_unread).to eq true - end - end - end - - context "is_starred parameter" do - context "when it is not malformed" do - it "marks a story as permanently starred" do - put "/stories/#{story_one.id}", { is_starred: true }.to_json - - expect(story_one.is_starred).to eq true - end - end - - context "when it is malformed" do - it "marks a story as permanently starred" do - put "/stories/#{story_one.id}", { is_starred: "malformed" }.to_json - - expect(story_one.is_starred).to eq true - end - end - end - end - - describe "POST /stories/mark_all_as_read" do - it "marks all unread stories as read and reload the page" do - expect_any_instance_of(MarkAllAsRead).to receive(:mark_as_read).once - - post "/stories/mark_all_as_read", story_ids: %w(1 2 3) - - expect(last_response.status).to be 302 - expect(URI.parse(last_response.location).path).to eq "/news" - end - end - - describe "GET /feed/:feed_id" do - it "looks for a particular feed" do - expect(FeedRepository).to receive(:fetch).with(story_one.feed.id.to_s).and_return(story_one.feed) - expect(StoryRepository).to receive(:feed).with(story_one.feed.id.to_s).and_return([story_one]) - - get "/feed/#{story_one.feed.id}" - end - - it "displays a list of stories" do - allow(FeedRepository).to receive(:fetch).and_return(story_one.feed) - allow(StoryRepository).to receive(:feed).and_return(stories) - - get "/feed/#{story_one.feed.id}" - - expect(last_response.body).to have_tag("#stories") - end - - it "differentiates between read and unread" do - allow(FeedRepository).to receive(:fetch).and_return(story_one.feed) - allow(StoryRepository).to receive(:feed).and_return(stories) - - story_one.is_read = false - story_two.is_read = true - - get "/feed/#{story_one.feed.id}" - - expect(last_response.body).to have_tag("li", class: "story") - expect(last_response.body).to have_tag("li", class: "unread") - end - end -end diff --git a/spec/factories/feed_factory.rb b/spec/factories/feed_factory.rb deleted file mode 100644 index ccad1c46f..000000000 --- a/spec/factories/feed_factory.rb +++ /dev/null @@ -1,27 +0,0 @@ -class FeedFactory - class FakeFeed < OpenStruct - def as_fever_json - { - id: id, - favicon_id: 0, - title: name, - url: url, - site_url: url, - is_spark: 0, - last_updated_on_time: last_fetched.to_i - } - end - end - - def self.build(params = {}) - FakeFeed.new( - id: rand(100), - group_id: params[:group_id] || rand(100), - name: params[:name] || Faker::Name.name + " on Software", - url: params[:url] || Faker::Internet.url, - last_fetched: params[:last_fetched] || Time.now, - stories: params[:stories] || [], - unread_stories: [] - ) - end -end diff --git a/spec/factories/feeds.rb b/spec/factories/feeds.rb new file mode 100644 index 000000000..624fb7cb7 --- /dev/null +++ b/spec/factories/feeds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:feed) do + user { default_user } + + sequence(:name, 100) { |n| "Feed #{n}" } + sequence(:url, 100) { |n| "http://exampoo.com/#{n}" } + + trait :with_group do + group + end + end +end diff --git a/spec/factories/group_factory.rb b/spec/factories/group_factory.rb deleted file mode 100644 index de9d16c2f..000000000 --- a/spec/factories/group_factory.rb +++ /dev/null @@ -1,16 +0,0 @@ -class GroupFactory - class FakeGroup < OpenStruct - def as_fever_json - { - id: id, - title: name - } - end - end - - def self.build(params = {}) - FakeGroup.new( - id: rand(100), - name: params[:name] || Faker::Name.name + " group") - end -end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb new file mode 100644 index 000000000..5d73411a0 --- /dev/null +++ b/spec/factories/groups.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:group) do + user { default_user } + sequence(:name, 100) { |n| "Group #{n}" } + end +end diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb new file mode 100644 index 000000000..9f0895c5b --- /dev/null +++ b/spec/factories/stories.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:story) do + feed + + sequence(:entry_id, 100) { |n| "entry-#{n}" } + sequence(:published, 100) { |n| n.days.ago } + + trait :read do + is_read { true } + end + + trait :starred do + is_starred { true } + end + + trait :with_group do + feed factory: [:feed, :with_group] + end + end +end diff --git a/spec/factories/story_factory.rb b/spec/factories/story_factory.rb deleted file mode 100644 index 0b4557465..000000000 --- a/spec/factories/story_factory.rb +++ /dev/null @@ -1,41 +0,0 @@ -require_relative "./feed_factory" - -class StoryFactory - class FakeStory < OpenStruct - def headline - title[0, 50] - end - - def source - feed.name - end - - def as_fever_json - { - id: id, - feed_id: feed_id, - title: title, - author: source, - html: body, - url: permalink, - is_saved: is_starred ? 1 : 0, - is_read: is_read ? 1 : 0, - created_on_time: published.to_i - } - end - end - - def self.build(params = {}) - default_params = { - id: rand(100), - title: Faker::Lorem.sentence, - permalink: Faker::Internet.url, - body: Faker::Lorem.paragraph, - feed: FeedFactory.build, - is_read: false, - is_starred: false, - published: Time.now - } - FakeStory.new(default_params.merge(params)) - end -end diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb deleted file mode 100644 index da91ad011..000000000 --- a/spec/factories/user_factory.rb +++ /dev/null @@ -1,11 +0,0 @@ -require_relative "./feed_factory" - -class UserFactory - class FakeUser < OpenStruct; end - - def self.build - FakeUser.new( - id: rand(100), - setup_complete: false) - end -end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..634283b3a --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory(:user) do + sequence(:username, 100) { |n| "user-#{n}" } + password { "super-secret" } + admin { false } + end +end diff --git a/spec/fever_api/authentication_spec.rb b/spec/fever_api/authentication_spec.rb deleted file mode 100644 index 0c96932eb..000000000 --- a/spec/fever_api/authentication_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "spec_helper" - -app_require "fever_api/authentication" - -describe FeverAPI::Authentication do - it "returns a hash with keys :auth and :last_refreshed_on_time" do - fake_clock = double("clock") - expect(fake_clock).to receive(:now).and_return(1234567890) - result = FeverAPI::Authentication.new(clock: fake_clock).call(double) - expect(result).to eq(auth: 1, last_refreshed_on_time: 1234567890) - end -end diff --git a/spec/fever_api/read_favicons_spec.rb b/spec/fever_api/read_favicons_spec.rb deleted file mode 100644 index 5b91e4b5e..000000000 --- a/spec/fever_api/read_favicons_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_favicons" - -describe FeverAPI::ReadFavicons do - subject { FeverAPI::ReadFavicons.new } - - it "returns a fixed icon list if requested" do - expect(subject.call("favicons" => nil)).to eq( - favicons: [ - { - id: 0, - data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" - } - ] - ) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/read_feeds_groups_spec.rb b/spec/fever_api/read_feeds_groups_spec.rb deleted file mode 100644 index 7da9707de..000000000 --- a/spec/fever_api/read_feeds_groups_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_feeds_groups" - -describe FeverAPI::ReadFeedsGroups do - let(:feed_ids) { [5, 7, 11] } - let(:feeds) { feed_ids.map { |id| double("feed", id: id, group_id: 1) } } - let(:feed_repository) { double("repo") } - - subject do - FeverAPI::ReadFeedsGroups.new(feed_repository: feed_repository) - end - - it "returns a list of groups requested through feeds" do - allow(feed_repository).to receive_message_chain(:in_group, :order).and_return(feeds) - - expect(subject.call("feeds" => nil)).to eq( - feeds_groups: [ - { - group_id: 1, - feed_ids: feed_ids.join(",") - } - ] - ) - end - - it "returns a list of groups requested through groups" do - allow(feed_repository).to receive_message_chain(:in_group, :order).and_return(feeds) - - expect(subject.call("groups" => nil)).to eq( - feeds_groups: [ - { - group_id: 1, - feed_ids: feed_ids.join(",") - } - ] - ) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/read_feeds_spec.rb b/spec/fever_api/read_feeds_spec.rb deleted file mode 100644 index e09fa0ab7..000000000 --- a/spec/fever_api/read_feeds_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_feeds" - -describe FeverAPI::ReadFeeds do - let(:feed_ids) { [5, 7, 11] } - let(:feeds) { feed_ids.map { |id| double("feed", id: id, as_fever_json: { id: id }) } } - let(:feed_repository) { double("repo") } - - subject do - FeverAPI::ReadFeeds.new(feed_repository: feed_repository) - end - - it "returns a list of feeds" do - expect(feed_repository).to receive(:list).and_return(feeds) - expect(subject.call("feeds" => nil)).to eq( - feeds: [ - { id: 5 }, - { id: 7 }, - { id: 11 } - ] - ) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/read_groups_spec.rb b/spec/fever_api/read_groups_spec.rb deleted file mode 100644 index b2c958bb6..000000000 --- a/spec/fever_api/read_groups_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_groups" - -describe FeverAPI::ReadGroups do - let(:group_1) { double("group_1", as_fever_json: { id: 1, title: "IT news" }) } - let(:group_2) { double("group_2", as_fever_json: { id: 2, title: "World news" }) } - let(:group_repository) { double("repo") } - - subject { FeverAPI::ReadGroups.new(group_repository: group_repository) } - - it "returns a group list if requested" do - expect(group_repository).to receive(:list).and_return([group_1, group_2]) - expect(subject.call("groups" => nil)).to eq( - groups: [ - { - id: 1, - title: "IT news" - }, - { - id: 2, - title: "World news" - } - ] - ) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/read_items_spec.rb b/spec/fever_api/read_items_spec.rb deleted file mode 100644 index a124341d0..000000000 --- a/spec/fever_api/read_items_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_items" - -describe FeverAPI::ReadItems do - let(:story_repository) { double("repo") } - - subject do - FeverAPI::ReadItems.new(story_repository: story_repository) - end - - it "returns a list of unread items including total count" do - stories = [ - double("story", as_fever_json: { id: 5 }), - double("story", as_fever_json: { id: 7 }), - double("story", as_fever_json: { id: 11 }) - ] - expect(story_repository).to receive(:unread).twice.and_return(stories) - expect(subject.call("items" => nil)).to eq( - items: [ - { id: 5 }, - { id: 7 }, - { id: 11 } - ], - total_items: 3 - ) - end - - it "returns a list of unread items since id including total count" do - unread_since_stories = [ - double("story", as_fever_json: { id: 5 }), - double("story", as_fever_json: { id: 7 }) - ] - expect(story_repository).to receive(:unread_since_id).with(3).and_return(unread_since_stories) - unread_stories = [ - double("story", as_fever_json: { id: 2 }), - double("story", as_fever_json: { id: 5 }), - double("story", as_fever_json: { id: 7 }) - ] - expect(story_repository).to receive(:unread).and_return(unread_stories) - expect(subject.call("items" => nil, since_id: 3)).to eq( - items: [ - { id: 5 }, - { id: 7 } - ], - total_items: 3 - ) - end - - it "returns a list of specified items including total count" do - stories = [ - double("story", as_fever_json: { id: 5 }), - double("story", as_fever_json: { id: 11 }) - ] - expect(story_repository).to receive(:fetch_by_ids).with(%w(5 11)).twice.and_return(stories) - - expect(subject.call("items" => nil, with_ids: "5,11")).to eq( - items: [ - { id: 5 }, - { id: 11 } - ], - total_items: 2 - ) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/read_links_spec.rb b/spec/fever_api/read_links_spec.rb deleted file mode 100644 index 895123de0..000000000 --- a/spec/fever_api/read_links_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "spec_helper" - -app_require "fever_api/read_links" - -describe FeverAPI::ReadLinks do - subject { FeverAPI::ReadLinks.new } - - it "returns a fixed link list if requested" do - expect(subject.call("links" => nil)).to eq(links: []) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/sync_saved_item_ids_spec.rb b/spec/fever_api/sync_saved_item_ids_spec.rb deleted file mode 100644 index e9efefb64..000000000 --- a/spec/fever_api/sync_saved_item_ids_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -app_require "fever_api/sync_saved_item_ids" - -describe FeverAPI::SyncSavedItemIds do - let(:story_ids) { [5, 7, 11] } - let(:stories) { story_ids.map { |id| double("story", id: id) } } - let(:story_repository) { double("repo") } - - subject do - FeverAPI::SyncSavedItemIds.new(story_repository: story_repository) - end - - it "returns a list of starred items if requested" do - expect(story_repository).to receive(:all_starred).and_return(stories) - expect(subject.call("saved_item_ids" => nil)).to eq(saved_item_ids: story_ids.join(",")) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/sync_unread_item_ids_spec.rb b/spec/fever_api/sync_unread_item_ids_spec.rb deleted file mode 100644 index b7f9925f4..000000000 --- a/spec/fever_api/sync_unread_item_ids_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -app_require "fever_api/sync_unread_item_ids" - -describe FeverAPI::SyncUnreadItemIds do - let(:story_ids) { [5, 7, 11] } - let(:stories) { story_ids.map { |id| double("story", id: id) } } - let(:story_repository) { double("repo") } - - subject do - FeverAPI::SyncUnreadItemIds.new(story_repository: story_repository) - end - - it "returns a list of unread items if requested" do - expect(story_repository).to receive(:unread).and_return(stories) - expect(subject.call("unread_item_ids" => nil)).to eq(unread_item_ids: story_ids.join(",")) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/write_mark_feed_spec.rb b/spec/fever_api/write_mark_feed_spec.rb deleted file mode 100644 index 793112034..000000000 --- a/spec/fever_api/write_mark_feed_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -app_require "fever_api/write_mark_feed" - -describe FeverAPI::WriteMarkFeed do - let(:feed_marker) { double("feed marker") } - let(:marker_class) { double("marker class") } - - subject do - FeverAPI::WriteMarkFeed.new(marker_class: marker_class) - end - - it "instantiates a feed marker and calls mark_feed_as_read if requested" do - expect(marker_class).to receive(:new).with(5, 1234567890).and_return(feed_marker) - expect(feed_marker).to receive(:mark_feed_as_read) - expect(subject.call(mark: "feed", id: 5, before: 1234567890)).to eq({}) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/write_mark_group_spec.rb b/spec/fever_api/write_mark_group_spec.rb deleted file mode 100644 index 1262f048c..000000000 --- a/spec/fever_api/write_mark_group_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -app_require "fever_api/write_mark_group" - -describe FeverAPI::WriteMarkGroup do - let(:group_marker) { double("group marker") } - let(:marker_class) { double("marker class") } - - subject do - FeverAPI::WriteMarkGroup.new(marker_class: marker_class) - end - - it "instantiates a group marker and calls mark_group_as_read if requested" do - expect(marker_class).to receive(:new).with(5, 1234567890).and_return(group_marker) - expect(group_marker).to receive(:mark_group_as_read) - expect(subject.call(mark: "group", id: 5, before: 1234567890)).to eq({}) - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api/write_mark_item_spec.rb b/spec/fever_api/write_mark_item_spec.rb deleted file mode 100644 index 2a2929409..000000000 --- a/spec/fever_api/write_mark_item_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -require "spec_helper" - -app_require "fever_api/write_mark_item" - -describe FeverAPI::WriteMarkItem do - let(:item_marker) { double("item marker") } - let(:marker_class) { double("marker class") } - - describe "as read" do - subject do - FeverAPI::WriteMarkItem.new(read_marker_class: marker_class) - end - - it "instantiates an item marker and calls mark_item_as_read if requested" do - expect(marker_class).to receive(:new).with(5).and_return(item_marker) - expect(item_marker).to receive(:mark_as_read) - expect(subject.call(mark: "item", as: "read", id: 5)).to eq({}) - end - end - - describe "as unread" do - subject do - FeverAPI::WriteMarkItem.new(unread_marker_class: marker_class) - end - - it "instantiates an item marker and calls mark_item_as_unread if requested" do - expect(marker_class).to receive(:new).with(5).and_return(item_marker) - expect(item_marker).to receive(:mark_as_unread) - expect(subject.call(mark: "item", as: "unread", id: 5)).to eq({}) - end - end - - describe "as starred" do - subject do - FeverAPI::WriteMarkItem.new(starred_marker_class: marker_class) - end - - it "instantiates an item marker and calls mark_item_as_starred if requested" do - expect(marker_class).to receive(:new).with(5).and_return(item_marker) - expect(item_marker).to receive(:mark_as_starred) - expect(subject.call(mark: "item", as: "saved", id: 5)).to eq({}) - end - end - - describe "as unstarred" do - subject do - FeverAPI::WriteMarkItem.new(unstarred_marker_class: marker_class) - end - - it "instantiates an item marker and calls mark_item_as_unstarred if requested" do - expect(marker_class).to receive(:new).with(5).and_return(item_marker) - expect(item_marker).to receive(:mark_as_unstarred) - expect(subject.call(mark: "item", as: "unsaved", id: 5)).to eq({}) - end - end - - it "returns an empty hash otherwise" do - expect(subject.call).to eq({}) - end -end diff --git a/spec/fever_api_spec.rb b/spec/fever_api_spec.rb deleted file mode 100644 index 011d0eb43..000000000 --- a/spec/fever_api_spec.rb +++ /dev/null @@ -1,243 +0,0 @@ -require "spec_helper" -require "./fever_api" - -describe FeverAPI do - include Rack::Test::Methods - - def app - FeverAPI::Endpoint - end - - let(:api_key) { "apisecretkey" } - let(:story_one) { StoryFactory.build } - let(:story_two) { StoryFactory.build } - let(:group) { GroupFactory.build } - let(:feed) { FeedFactory.build(group_id: group.id) } - let(:stories) { [story_one, story_two] } - let(:standard_answer) do - { api_version: 3, auth: 1, last_refreshed_on_time: 123456789 } - end - let(:cannot_auth) do - { api_version: 3, auth: 0 } - end - let(:headers) { { api_key: api_key } } - - before do - user = double(api_key: api_key) - allow(User).to receive(:first) { user } - - allow(Time).to receive(:now) { Time.at(123456789) } - end - - def last_response_as_object - JSON.parse(last_response.body, symbolize_names: true) - end - - describe "authentication" do - it "authenticates request with correct api_key" do - get "/", headers - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "does not authenticate request with incorrect api_key" do - get "/", api_key: "foo" - expect(last_response).to be_ok - expect(last_response_as_object).to include(cannot_auth) - end - - it "does not authenticate request when api_key is not provided" do - get "/" - expect(last_response).to be_ok - expect(last_response_as_object).to include(cannot_auth) - end - end - - describe "#get" do - def make_request(extra_headers = {}) - get "/", headers.merge(extra_headers) - end - - it "returns standard answer" do - make_request - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "returns groups and feeds by groups when 'groups' header is provided" do - allow(GroupRepository).to receive(:list).and_return([group]) - allow(FeedRepository).to receive_message_chain(:in_group, :order).and_return([feed]) - - make_request(groups: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - groups: [group.as_fever_json], - feeds_groups: [{ group_id: group.id, feed_ids: feed.id.to_s }] - ) - end - - it "returns feeds and feeds by groups when 'feeds' header is provided" do - allow(FeedRepository).to receive(:list).and_return([feed]) - allow(FeedRepository).to receive_message_chain(:in_group, :order).and_return([feed]) - - make_request(feeds: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - feeds: [feed.as_fever_json], - feeds_groups: [{ group_id: group.id, feed_ids: feed.id.to_s }] - ) - end - - it "returns favicons hash when 'favicons' header provided" do - make_request(favicons: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - favicons: [ - { - id: 0, - data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" - } - ] - ) - end - - it "returns stories when 'items' header is provided along with 'since_id'" do - expect(StoryRepository).to receive(:unread_since_id).with("5").and_return([story_one]) - expect(StoryRepository).to receive(:unread).and_return([story_one, story_two]) - - make_request(items: nil, since_id: 5) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - items: [story_one.as_fever_json], - total_items: 2 - ) - end - - it "returns stories when 'items' header is provided without 'since_id'" do - expect(StoryRepository).to receive(:unread).twice.and_return([story_one, story_two]) - - make_request(items: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - items: [story_one.as_fever_json, story_two.as_fever_json], - total_items: 2 - ) - end - - it "returns stories ids when 'items' header is provided along with 'with_ids'" do - expect(StoryRepository).to receive(:fetch_by_ids).twice.with(["5"]).and_return([story_one]) - - make_request(items: nil, with_ids: 5) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - items: [story_one.as_fever_json], - total_items: 1 - ) - end - - it "returns links as empty array when 'links' header is provided" do - make_request(links: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include(links: []) - end - - it "returns unread items ids when 'unread_item_ids' header is provided" do - expect(StoryRepository).to receive(:unread).and_return([story_one, story_two]) - - make_request(unread_item_ids: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - unread_item_ids: [story_one.id, story_two.id].join(",") - ) - end - - it "returns starred items when 'saved_item_ids' header is provided" do - expect(Story).to receive(:where).with(is_starred: true).and_return([story_one, story_two]) - - make_request(saved_item_ids: nil) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - expect(last_response_as_object).to include( - saved_item_ids: [story_one.id, story_two.id].join(",") - ) - end - end - - describe "#post" do - def make_request(extra_headers = {}) - post "/", headers.merge(extra_headers) - end - - it "commands to mark story as read" do - expect(MarkAsRead).to receive(:new).with("10").and_return(double(mark_as_read: true)) - - make_request(mark: "item", as: "read", id: 10) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "commands to mark story as unread" do - expect(MarkAsUnread).to receive(:new).with("10").and_return(double(mark_as_unread: true)) - - make_request(mark: "item", as: "unread", id: 10) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "commands to save story" do - expect(MarkAsStarred).to receive(:new).with("10").and_return(double(mark_as_starred: true)) - - make_request(mark: "item", as: "saved", id: 10) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "commands to unsave story" do - expect(MarkAsUnstarred).to receive(:new).with("10").and_return(double(mark_as_unstarred: true)) - - make_request(mark: "item", as: "unsaved", id: 10) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "commands to mark group as read" do - expect(MarkGroupAsRead).to receive(:new).with("10", "1375080946").and_return(double(mark_group_as_read: true)) - - make_request(mark: "group", as: "read", id: 10, before: 1375080946) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - - it "commands to mark entire feed as read" do - expect(MarkFeedAsRead).to receive(:new).with("20", "1375080945").and_return(double(mark_feed_as_read: true)) - - make_request(mark: "feed", as: "read", id: 20, before: 1375080945) - - expect(last_response).to be_ok - expect(last_response_as_object).to include(standard_answer) - end - end -end diff --git a/spec/fixtures/feeds.opml b/spec/fixtures/feeds.opml new file mode 100755 index 000000000..832f59b48 --- /dev/null +++ b/spec/fixtures/feeds.opml @@ -0,0 +1,29 @@ + + + + subscriptions title + + + + + + + + + + + + + + + diff --git a/spec/helpers/authentications_helper_spec.rb b/spec/helpers/authentications_helper_spec.rb deleted file mode 100644 index 325dce20b..000000000 --- a/spec/helpers/authentications_helper_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "spec_helper" - -app_require "helpers/authentication_helpers" - -RSpec.describe Sinatra::AuthenticationHelpers do - class Helper - include Sinatra::AuthenticationHelpers - end - - let(:helper) { Helper.new } - - describe "#needs_authentication?" do - let(:authenticated_path) { "/news" } - - before do - stub_const("ENV", "RACK_ENV" => "not-test") - allow(UserRepository).to receive(:setup_complete?).and_return(true) - end - - context "when `RACK_ENV` is 'test'" do - it "returns false" do - stub_const("ENV", "RACK_ENV" => "test") - - expect(helper.needs_authentication?(authenticated_path)).to eq(false) - end - end - - context "when setup in not complete" do - it "returns false" do - allow(UserRepository).to receive(:setup_complete?).and_return(false) - - expect(helper.needs_authentication?(authenticated_path)).to eq(false) - end - end - - %w(/login /logout /heroku).each do |path| - context "when `path` is '#{path}'" do - it "returns false" do - expect(helper.needs_authentication?(path)).to eq(false) - end - end - end - - %w(css js img).each do |path| - context "when `path` contains '#{path}'" do - it "returns false" do - expect(helper.needs_authentication?("/#{path}/file.ext")).to eq(false) - end - end - end - - context "otherwise" do - it "returns true" do - expect(helper.needs_authentication?(authenticated_path)).to eq(true) - end - end - end -end diff --git a/spec/helpers/url_helers_spec.rb b/spec/helpers/url_helpers_spec.rb similarity index 58% rename from spec/helpers/url_helers_spec.rb rename to spec/helpers/url_helpers_spec.rb index 6ed0288bf..1ba7126d0 100644 --- a/spec/helpers/url_helers_spec.rb +++ b/spec/helpers/url_helpers_spec.rb @@ -1,66 +1,64 @@ -require "spec_helper" - -app_require "helpers/url_helpers" +# frozen_string_literal: true RSpec.describe UrlHelpers do - class Helper - include UrlHelpers + def helper + helper_class = Class.new { include UrlHelpers } + helper_class.new end - let(:helper) { Helper.new } - describe "#expand_absolute_urls" do it "preserves existing absolute urls" do content = 'bar' - expect(helper.expand_absolute_urls(content, nil)).to eq content + expect(helper.expand_absolute_urls(content, nil)).to eq(content) end it "replaces relative urls in a, img and video tags" do - content = <<-EOS -
            - -tee - -
            - EOS + content = <<~HTML +
            + + tee + +
            + HTML result = helper.expand_absolute_urls(content, "http://oodl.io/d/") - expect(result.delete("\n")).to eq <<-EOS.delete("\n") -
            - -tee - - -
            - EOS + expect(result.delete("\n")).to eq(<<~HTML.delete("\n")) +
            + + tee + + +
            + HTML end it "handles empty body" do - expect(helper.expand_absolute_urls("", nil)).to eq "" + expect(helper.expand_absolute_urls("", nil)).to eq("") end it "doesn't modify tags that do not have url attributes" do - content = <<-EOS -
            - - - -
            - EOS + content = <<~HTML +
            + + + +
            + HTML result = helper.expand_absolute_urls(content, "http://oodl.io/d/") - expect(result.delete("\n")).to eq <<-EOS.delete("\n") -
            - - - -
            - EOS + expect(result.delete("\n")).to eq(<<~HTML.delete("\n")) +
            + + + +
            + HTML end it "leaves the url as-is if it cannot be parsed" do - weird_url = "https://github.com/aphyr/jepsen/blob/" \ + weird_url = + "https://github.com/aphyr/jepsen/blob/" \ "1403f2d6e61c595bafede0d404fd4a893371c036/" \ "elasticsearch/src/jepsen/system/elasticsearch.clj#" \ "L161-L226.%20Then%20we'll%20write%20a%20%5Bregister%20test%5D(" \ @@ -77,12 +75,12 @@ class Helper describe "#normalize_url" do it "resolves scheme-less urls" do - %w(http https).each do |scheme| + ["http", "https"].each do |scheme| feed_url = "#{scheme}://blog.golang.org/feed.atom" url = helper.normalize_url("//blog.golang.org/context", feed_url) - expect(url).to eq "#{scheme}://blog.golang.org/context" + expect(url).to eq("#{scheme}://blog.golang.org/context") end end @@ -98,7 +96,7 @@ class Helper url = helper.normalize_url( "//blog.golang.org/context", "//blog.golang.org/feed.atom" ) - expect(url).to eq "http://blog.golang.org/context" + expect(url).to eq("http://blog.golang.org/context") end it "resolves relative urls" do @@ -106,7 +104,7 @@ class Helper "/progrium/dokku/releases/tag/v0.4.4", "https://github.com/progrium/dokku/releases.atom" ) - expect(url).to eq "https://github.com/progrium/dokku/releases/tag/v0.4.4" + expect(url).to eq("https://github.com/progrium/dokku/releases/tag/v0.4.4") end end end diff --git a/spec/integration/feed_importing_spec.rb b/spec/integration/feed_importing_spec.rb index 699a9e696..4755f8af3 100644 --- a/spec/integration/feed_importing_spec.rb +++ b/spec/integration/feed_importing_spec.rb @@ -1,94 +1,88 @@ -require "spec_helper" -require "time" -require "support/active_record" -require "support/feed_server" -require "capybara" -require "capybara/server" -require "timecop" +# frozen_string_literal: true -app_require "tasks/fetch_feed" +require "support/feed_server" -describe "Feed importing" do - before(:all) do - @server = FeedServer.new +RSpec.describe "Feed importing" do + def create_server(url: "feed01_valid_feed/feed.xml") + server = FeedServer.new + server.response = sample_data(url) + server end - let(:feed) do - Feed.create( + def create_feed(**) + create( + :feed, name: "Example feed", - last_fetched: Time.new(2014, 1, 1), - url: @server.url + last_fetched: Time.zone.local(2014, 1, 1), + ** ) end describe "Valid feed" do - before(:all) do - # articles older than 3 days are ignored, so freeze time within - # applicable range of the stories in the sample feed - Timecop.freeze Time.parse("2014-08-15T17:30:00Z") - end + # articles older than 3 days are ignored, so freeze time within + # applicable range of the stories in the sample feed + # travel_to(Time.parse("2014-08-15T17:30:00Z")) describe "Importing for the first time" do it "imports all entries" do - @server.response = sample_data("feeds/feed01_valid_feed/feed.xml") - expect { fetch_feed(feed) }.to change { feed.stories.count }.to(5) + travel_to(Time.parse("2014-08-15T17:30:00Z")) + feed = create_feed(url: create_server.url) + expect { Feed::FetchOne.call(feed) } + .to change(feed.stories, :count).to(5) end end describe "Importing for the second time" do - before(:each) do - @server.response = sample_data("feeds/feed01_valid_feed/feed.xml") - fetch_feed(feed) - end - context "no new entries" do it "does not create new stories" do - @server.response = sample_data("feeds/feed01_valid_feed/feed.xml") - expect { fetch_feed(feed) }.to_not change { feed.stories.count } + travel_to(Time.parse("2014-08-15T17:30:00Z")) + feed = create_feed(url: create_server.url) + Feed::FetchOne.call(feed) + expect { Feed::FetchOne.call(feed) } + .not_to change(feed.stories, :count) end end context "new entries" do it "creates new stories" do - @server.response = sample_data("feeds/feed01_valid_feed/feed_updated.xml") - expect { fetch_feed(feed) }.to change { feed.stories.count }.by(1) + travel_to(Time.parse("2014-08-15T17:30:00Z")) + server = create_server + feed = create_feed(url: server.url) + Feed::FetchOne.call(feed) + server.response = sample_data("feed01_valid_feed/feed_updated.xml") + expect { Feed::FetchOne.call(feed) } + .to change(feed.stories, :count).by(1) end end end end describe "Feed with incorrect pubdates" do - before(:all) do - Timecop.freeze Time.parse("2014-08-12T17:30:00Z") - end - context "has been fetched before" do + url = "feed02_invalid_published_dates/feed.xml" + last_fetched = Time.parse("2014-08-12T00:01:00Z") + it "imports all new stories" do # This spec describes a scenario where the feed is reporting incorrect - # published dates for stories. - # The feed in question is feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots. - # When an article is published the published date is always set to 00:00 of - # the day the article was published. - # This specs shows that with the current behaviour (08-15-2014) Stringer - # will not detect this article, if the last time this feed was fetched is - # after 00:00 the day the article was published. + # published dates for stories. The feed in question is + # feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots. When an + # article is published the published date is always set to 00:00 of the + # day the article was published. This specs shows that with the current + # behaviour (08-15-2014) Stringer will not detect this article, if the + # last time this feed was fetched is after 00:00 the day the article + # was published. - feed.last_fetched = Time.parse("2014-08-12T00:01:00Z") - @server.response = sample_data("feeds/feed02_invalid_published_dates/feed.xml") + travel_to(Time.parse("2014-08-12T17:30:00Z")) + server = create_server(url:) + feed = create_feed(url: server.url, last_fetched:) - expect { fetch_feed(feed) }.to change { feed.stories.count }.by(1) + expect { Feed::FetchOne.call(feed) } + .to change(feed.stories, :count).by(1) end end end end def sample_data(path) - File.new(File.join("spec", "sample_data", path)).read -end - -def fetch_feed(feed) - logger = Logger.new(STDOUT) - logger.level = Logger::DEBUG - - FetchFeed.new(feed, logger: logger).fetch + File.new(File.join("spec", "sample_data", "feeds", path)).read end diff --git a/spec/javascript/spec/spec_helper.js b/spec/javascript/spec/spec_helper.js index a35e11d48..e18c11a27 100644 --- a/spec/javascript/spec/spec_helper.js +++ b/spec/javascript/spec/spec_helper.js @@ -1,3 +1,7 @@ +if (typeof initMochaPhantomJS === 'function') { + initMochaPhantomJS() +} + mocha.ui('bdd'); chai.should(); diff --git a/spec/javascript/spec/views/story_view_spec.js b/spec/javascript/spec/views/story_view_spec.js index c7a556c97..737ac7f5e 100644 --- a/spec/javascript/spec/views/story_view_spec.js +++ b/spec/javascript/spec/views/story_view_spec.js @@ -9,6 +9,7 @@ describe("Storyiew", function(){ before(function() { this.story = new Story({ source: "TechKrunch", + enclosure_url: null, headline: "Every startups acquired by Yahoo!", lead: "This is the lead.", title: "Every startups acquired by Yahoo! NOT!!", @@ -34,6 +35,10 @@ describe("Storyiew", function(){ el.find(tagName).should.have.length(count); }; + var assertNoTagExists = function(el, tagName) { + el.find(tagName).should.have.length(0); + }; + var assertPropertyRendered = function(el, model, propName) { el.html().should.have.string(model.get(propName)); }; @@ -104,6 +109,18 @@ describe("Storyiew", function(){ assertTagExists(this.view.$el, ".story-starred .icon-star", 2); }); + it("should not render enclosure link when not present", function(){ + assertNoTagExists(this.view.$el, ".story-enclosure"); + }); + + it("should render enclosure link when present", function(){ + this.story.set("enclosure_url", "http://example.com/enclosure"); + this.view.render(); + + assertTagExists(this.view.$el, ".story-enclosure"); + assertPropertyRendered(this.view.$el, this.story, "enclosure_url"); + }); + describe("Handling click on story", function(){ beforeEach(function() { this.toggle_stub = sinon.stub(this.story, "toggle"); diff --git a/spec/javascript/support/views/index.erb b/spec/javascript/support/views/test/index.html.erb similarity index 83% rename from spec/javascript/support/views/index.erb rename to spec/javascript/support/views/test/index.html.erb index 52ec0d503..bf129cce1 100644 --- a/spec/javascript/support/views/index.erb +++ b/spec/javascript/support/views/test/index.html.erb @@ -1,5 +1,5 @@ - + Stringer JavaScript Test Suite @@ -13,10 +13,7 @@ <% end %> - <% js_templates.each do |template| %> - <%= template %> - <%= render_js_template template.to_sym %> - <% end %> + <%= render("stories/templates") %>
            diff --git a/spec/javascript/test_controller.rb b/spec/javascript/test_controller.rb index c79b0993e..57f461f86 100644 --- a/spec/javascript/test_controller.rb +++ b/spec/javascript/test_controller.rb @@ -1,55 +1,53 @@ -class Stringer < Sinatra::Base - def self.test_path(*chunks) - File.expand_path(File.join("..", *chunks), __FILE__) - end +# frozen_string_literal: true + +class TestController < ApplicationController + skip_before_action :verify_authenticity_token - get "/test" do - erb File.read(self.class.test_path("support", "views", "index.erb")), layout: false, locals: { - js_files: js_files, - js_templates: js_templates - } + def index + authorization.skip + prepend_view_path(test_path("support", "views")) + render(layout: false, locals: { js_files: }) end - get "/spec/*" do - send_file self.class.test_path("spec", *params[:splat]) + def spec + authorization.skip + send_file(test_path("spec", "#{params[:splat]}.js")) end - get "/vendor/*" do - send_file self.class.test_path("support", "vendor", *params[:splat]) + def vendor + authorization.skip + + filename = "#{params[:splat]}.#{params[:format]}" + send_file(test_path("support", "vendor", params[:format], filename)) end private + def test_path(*) + File.expand_path(File.join(__dir__, *)) + end + def vendor_js_files - %w(mocha.js sinon.js chai.js chai-changes.js chai-backbone.js sinon-chai.js).map do |name| - File.join "vendor", "js", name - end + [ + "vendor/js/mocha.js", + "vendor/js/sinon.js", + "vendor/js/chai.js", + "vendor/js/chai-changes.js", + "vendor/js/chai-backbone.js", + "vendor/js/sinon-chai.js" + ] end def vendor_css_files - %w(mocha.css).map do |name| - File.join "vendor", "css", name - end + ["vendor/css/mocha.css"] end def js_helper_files - %w(spec_helper.js).map do |name| - File.join "spec", name - end - end - - def js_lib_files - base = self.class.test_path("..", "..", "app", "public") - Dir[File.join(base, "js", "**", "*.js")].map do |lib_file| - lib_file.sub!(base, "") - end + ["spec/spec_helper.js"] end def js_test_files - base = self.class.test_path - Dir[File.join(base, "**", "*_spec.js")].map do |spec_file| - spec_file.sub!(base, "") - end + ["/spec/models/story_spec.js", "/spec/views/story_view_spec.js"] end def js_files @@ -59,8 +57,5 @@ def js_files def css_files vendor_css_files end - - def js_templates - [:story] - end + helper_method :css_files end diff --git a/spec/jobs/callable_job_spec.rb b/spec/jobs/callable_job_spec.rb new file mode 100644 index 000000000..2ca4b33e7 --- /dev/null +++ b/spec/jobs/callable_job_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.describe CallableJob do + it "calls the callable" do + callable = ->(*, **) {} + + expect { described_class.perform_now(callable, "foo", bar: "baz") } + .to invoke(:call).on(callable).with("foo", bar: "baz") + end +end diff --git a/spec/lib/admin_constraint_spec.rb b/spec/lib/admin_constraint_spec.rb new file mode 100644 index 000000000..09efd1cc6 --- /dev/null +++ b/spec/lib/admin_constraint_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe AdminConstraint do + def make_request(session: {}) + request = ActionDispatch::Request.new({}) + request.session = session + request + end + + it "matches when the session user is an admin" do + user = create(:user, admin: true) + request = make_request(session: { user_id: user.id }) + + expect(described_class.new.matches?(request)).to be(true) + end + + it "does not match when the session user is not an admin" do + user = create(:user) + request = make_request(session: { user_id: user.id }) + + expect(described_class.new.matches?(request)).to be(false) + end + + it "does not match when there is no session user" do + request = make_request + + expect(described_class.new.matches?(request)).to be(false) + end +end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb new file mode 100644 index 000000000..c02d0c3e9 --- /dev/null +++ b/spec/models/application_record_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe ApplicationRecord do + describe ".boolean_accessor" do + with_model :Cheese, superclass: described_class do + table { |t| t.jsonb(:options, default: {}) } + end + + describe "#" do + it "returns the value when present" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new(options: { stinky: true }) + + expect(cheese.stinky).to be(true) + end + + it "returns false by default" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + expect(cheese.stinky).to be(false) + end + + it "returns the default when value is nil" do + Cheese.boolean_accessor(:options, :stinky, default: true) + cheese = Cheese.new + + expect(cheese.stinky).to be(true) + end + + it "casts the value to a boolean" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new(options: { stinky: "true" }) + + expect(cheese.stinky).to be(true) + end + end + + describe "#=" do + it "sets the value" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + cheese.stinky = true + + expect(cheese.options).to eq({ "stinky" => true }) + end + + it "casts the value to a boolean" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + cheese.stinky = "true" + + expect(cheese.options).to eq({ "stinky" => true }) + end + + it "uses the default when value is nil" do + Cheese.boolean_accessor(:options, :stinky, default: true) + cheese = Cheese.new + + cheese.stinky = nil + + expect(cheese.options).to eq({ "stinky" => true }) + end + end + end +end diff --git a/spec/models/feed_spec.rb b/spec/models/feed_spec.rb new file mode 100644 index 000000000..a78f05c25 --- /dev/null +++ b/spec/models/feed_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.describe "Feed" do + describe ".with_unread_stories_counts" do + it "returns feeds with unread stories counts" do + create(:story) + + feed = Feed.with_unread_stories_counts.first + + expect(feed.unread_stories_count).to eq(1) + end + + it "includes feeds with no unread stories" do + create(:story, :read) + + feed = Feed.with_unread_stories_counts.first + + expect(feed.unread_stories_count).to eq(0) + end + end + + describe "#unread_stories" do + it "returns stories where is_read is false" do + feed = create(:feed) + story = create(:story, feed:) + + expect(feed.unread_stories).to eq([story]) + end + + it "does not return stories where is_read is true" do + feed = create(:feed) + create(:story, :read, feed:) + + expect(feed.unread_stories).to be_empty + end + end + + describe "#status_bubble" do + it "returns 'yellow' when status == 'red' and there are stories" do + feed = Feed.new(status: :red, stories: [Story.new]) + + expect(feed.status_bubble).to eq("yellow") + end + + it "returns 'red' if status is 'red' and there are no stories" do + feed = Feed.new(status: :red) + + expect(feed.status_bubble).to eq("red") + end + + it "returns nil when no status is set" do + feed = Feed.new + + expect(feed.status_bubble).to be_nil + end + + it "returns :green when status is :green" do + feed = Feed.new(status: :green) + + expect(feed.status_bubble).to eq("green") + end + end + + describe "#as_fever_json" do + it "returns a hash of the feed in fever format" do + last_fetched = 1.day.ago + feed = Feed.new( + id: 52, + name: "chicken feed", + url: "wat url", + last_fetched: + ) + + expect(feed.as_fever_json).to eq( + id: 52, + favicon_id: 0, + title: "chicken feed", + url: "wat url", + site_url: "wat url", + is_spark: 0, + last_updated_on_time: last_fetched.to_i + ) + end + + it "replaces a null title with an empty string" do + last_fetched = 1.day.ago + feed = Feed.new(id: 52, name: nil, url: "wat url", last_fetched:) + + expect(feed.as_fever_json).to eq( + id: 52, + favicon_id: 0, + title: "", + url: "wat url", + site_url: "wat url", + is_spark: 0, + last_updated_on_time: last_fetched.to_i + ) + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb new file mode 100644 index 000000000..6ee186679 --- /dev/null +++ b/spec/models/group_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe Group do + describe "#as_fever_json" do + it "returns a hash of the group in fever format" do + group = described_class.new(id: 5, name: "wat group") + + expect(group.as_fever_json).to eq(id: 5, title: "wat group") + end + end +end diff --git a/spec/models/migration_status_spec.rb b/spec/models/migration_status_spec.rb index e05961abc..d509990b2 100644 --- a/spec/models/migration_status_spec.rb +++ b/spec/models/migration_status_spec.rb @@ -1,21 +1,17 @@ -require "spec_helper" -require "support/active_record" +# frozen_string_literal: true -app_require "models/migration_status" +RSpec.describe "MigrationStatus" do + it "returns array of strings representing pending migrations" do + migrator = ActiveRecord::Base.connection.pool.migration_context.open -describe "MigrationStatus" do - describe "pending_migrations" do - it "returns array of strings representing pending migrations" do - migrator = double "Migrator" - allow(migrator).to receive(:migrations).and_return [ - double("First Migration", name: "Migration A", version: 1), - double("Second Migration", name: "Migration B", version: 2), - double("Third Migration", name: "Migration C", version: 3) + allow(migrator).to receive(:pending_migrations).and_return( + [ + ActiveRecord::Migration.new("Migration B", 2), + ActiveRecord::Migration.new("Migration C", 3) ] - allow(migrator).to receive(:migrations_path) - allow(migrator).to receive(:current_version).and_return 1 + ) + allow(ActiveRecord::Migrator).to receive(:new).and_return(migrator) - expect(MigrationStatus.new(migrator).pending_migrations).to eq ["Migration B - 2", "Migration C - 3"] - end + expect(MigrationStatus.call).to eq(["Migration B - 2", "Migration C - 3"]) end end diff --git a/spec/models/setting/user_signup_spec.rb b/spec/models/setting/user_signup_spec.rb new file mode 100644 index 000000000..847443043 --- /dev/null +++ b/spec/models/setting/user_signup_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Setting::UserSignup do + describe ".first" do + it "returns the first record" do + setting = described_class.create! + + expect(described_class.first).to eq(setting) + end + + it "creates a record if one does not already exist" do + expect { described_class.first }.to change(described_class, :count).by(1) + end + end + + describe ".enabled?" do + it "returns true when enabled" do + create(:user) + described_class.create!(enabled: true) + + expect(described_class.enabled?).to be(true) + end + + it "returns false when disabled" do + create(:user) + described_class.create!(enabled: false) + + expect(described_class.enabled?).to be(false) + end + + it "returns true when no users exist" do + described_class.create!(enabled: false) + + expect(described_class.enabled?).to be(true) + end + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb index 3ca12d381..247792ff7 100644 --- a/spec/models/story_spec.rb +++ b/spec/models/story_spec.rb @@ -1,49 +1,159 @@ -require "spec_helper" -require "support/active_record" +# frozen_string_literal: true -app_require "models/story" +RSpec.describe "Story" do + describe ".unread" do + it "returns stories where is_read is false" do + story = create(:story) -describe "Story" do - let(:story) do - Story.new( - title: Faker::Lorem.sentence(50), - body: Faker::Lorem.sentence(50) - ) + expect(Story.unread).to eq([story]) + end + + it "does not return stories where is_read is true" do + create(:story, :read) + + expect(Story.unread).to be_empty + end end describe "#headline" do it "truncates to 50 chars" do + story = Story.new(title: "a" * 100) + expect(story.headline.size).to eq(50) end it "uses a fallback string if story has no title" do + story = build_stubbed(:story) story.title = nil expect(story.headline).to eq(Story::UNTITLED) end it "strips html out" do + story = build_stubbed(:story) story.title = "Super cool stuff" - expect(story.headline).to eq "Super cool stuff" + expect(story.headline).to eq("Super cool stuff") end end describe "#lead" do it "truncates to 100 chars" do + story = Story.new(body: "a" * 1000) + expect(story.lead.size).to eq(100) end it "strips html out" do + story = build_stubbed(:story) story.body = "Yo dawg" - expect(story.lead).to eq "Yo dawg" + expect(story.lead).to eq("Yo dawg") end end describe "#source" do - let(:feed) { Feed.new(name: "Superfeed") } - before { story.feed = feed } - it "returns the feeds name" do + story = build_stubbed(:story) + feed = Feed.new(name: "Superfeed") + story.feed = feed + expect(story.source).to eq(feed.name) end end + + describe "#pretty_date" do + it "returns a formatted published date" do + published_at = Time.zone.now + story = Story.new(published: published_at) + + expect(story.pretty_date).to eq(I18n.l(published_at)) + end + + it "raises an error when published is nil" do + story = Story.new + + expect { story.pretty_date }.to raise_error(ArgumentError) + end + end + + describe "#as_json" do + it "returns a hash of the story" do + feed = create(:feed, name: "my feed") + published_at = 1.day.ago + created_at = 1.hour.ago + updated_at = 1.minute.ago + story = create( + :story, + body: "story body", + created_at:, + entry_id: 5, + feed:, + is_read: true, + is_starred: false, + keep_unread: true, + permalink: "www.exampoo.com/perma", + published: published_at, + title: "the story title", + updated_at: + ) + + expect(story.as_json).to eq( + { + body: "story body", + created_at: created_at.utc.as_json, + enclosure_url: nil, + entry_id: "5", + feed_id: feed.id, + headline: "the story title", + id: story.id, + is_read: true, + is_starred: false, + keep_unread: true, + lead: "story body", + permalink: "www.exampoo.com/perma", + pretty_date: I18n.l(published_at.utc), + published: published_at.utc.as_json, + source: "my feed", + title: "the story title", + updated_at: updated_at.utc.as_json + }.stringify_keys + ) + end + end + + describe "#as_fever_json" do + it "returns a hash of the story in fever format" do + feed = create(:feed, name: "my feed") + published_at = 1.day.ago + story = create( + :story, + feed:, + title: "the story title", + body: "story body", + permalink: "www.exampoo.com/perma", + published: published_at, + is_read: true + ) + + expect(story.as_fever_json).to eq( + id: story.id, + feed_id: feed.id, + title: "the story title", + author: "my feed", + html: "story body", + url: "www.exampoo.com/perma", + is_saved: 0, + is_read: 1, + created_on_time: published_at.to_i + ) + end + + it "returns is_read as 0 if story is unread" do + story = create(:story, is_read: false) + expect(story.as_fever_json[:is_read]).to eq(0) + end + + it "returns is_saved as 1 if story is starred" do + story = create(:story, is_starred: true) + expect(story.as_fever_json[:is_saved]).to eq(1) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..a194f80b1 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe User do + describe "#update_api_key" do + it "updates the api key when the username changed" do + user = create(:user, username: "stringer", password: "super-secret") + user.update!(username: "booger") + + expect(user.api_key).to eq(Digest::MD5.hexdigest("booger:super-secret")) + end + + it "updates the api key when the password changed" do + user = create(:user, username: "stringer", password: "super-secret") + user.update!(password: "new-password") + + expect(user.api_key).to eq(Digest::MD5.hexdigest("stringer:new-password")) + end + + it "does nothing when the username and password have not changed" do + user = create(:user, username: "stringer", password: "super-secret") + user = described_class.find(user.id) + + expect { user.save! }.to not_change(user, :api_key).and not_raise_error + end + + it "raises an error when password and password challenge are blank" do + user = create(:user, username: "stringer", password: "super-secret") + user = described_class.find(user.id) + expected_message = "Cannot change username without providing a password" + + expect { user.update!(username: "booger") } + .to raise_error(ActiveRecord::ActiveRecordError, expected_message) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 000000000..ced21387b --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "spec_helper" +ENV["RAILS_ENV"] ||= "test" + +require_relative "support/coverage" +require_relative "../config/environment" +# Prevent database truncation if the environment is production +if Rails.env.production? + abort("The Rails environment is running in production mode!") +end +require "rspec/rails" + +Rails.root.glob("spec/support/*.rb").each { |path| require path } + +# Checks for pending migrations and applies them before tests are run. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort(e.to_s.strip) +end + +RSpec.configure do |config| + config.include(RequestHelpers, type: :request) + config.include(SystemHelpers, type: :system) + config.include(ActiveSupport::Testing::TimeHelpers) + + config.use_transactional_fixtures = true + + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/repositories/feed_repository_spec.rb b/spec/repositories/feed_repository_spec.rb index 1951019f5..864474988 100644 --- a/spec/repositories/feed_repository_spec.rb +++ b/spec/repositories/feed_repository_spec.rb @@ -1,73 +1,119 @@ -require "spec_helper" -require "support/active_record" +# frozen_string_literal: true -app_require "repositories/feed_repository" +RSpec.describe FeedRepository do + describe ".fetch" do + it "finds and returns found feed" do + feed = create(:feed) -describe FeedRepository do - describe ".update_last_fetched" do - let(:timestamp) { Time.now } + result = described_class.fetch(feed.id) - it "saves the last_fetched timestamp" do - feed = Feed.new + expect(result).to eq(feed) + end + end + + describe ".fetch_by_ids" do + it "finds all feeds by id" do + feeds = create_pair(:feed) + + expect(described_class.fetch_by_ids(feeds.map(&:id))) + .to match_array(feeds) + end - FeedRepository.update_last_fetched(feed, timestamp) + it "does not find other feeds" do + feed1, = create_pair(:feed) - expect(feed.last_fetched).to eq timestamp + expect(described_class.fetch_by_ids(feed1.id)).to eq([feed1]) end + end + + describe ".update_feed" do + it "saves the name and url" do + feed = build(:feed) - let(:weird_timestamp) { Time.parse("Mon, 01 Jan 0001 00:00:00 +0100") } + described_class.update_feed(feed, "Test Feed", "example.com/feed") + + expect(feed.name).to eq("Test Feed") + expect(feed.url).to eq("example.com/feed") + end + end + + describe ".update_last_fetched" do + it "saves the last_fetched timestamp" do + timestamp = Time.zone.now.round + feed = build(:feed) + + described_class.update_last_fetched(feed, timestamp) + + expect(feed.last_fetched).to eq(timestamp) + end it "rejects weird timestamps" do + timestamp = Time.zone.now.round + weird_timestamp = Time.parse("Mon, 01 Jan 0001 00:00:00 +0100") feed = Feed.new(last_fetched: timestamp) - FeedRepository.update_last_fetched(feed, weird_timestamp) + described_class.update_last_fetched(feed, weird_timestamp) - expect(feed.last_fetched).to eq timestamp + expect(feed.last_fetched).to eq(timestamp) end - it "doesn't update if timestamp is nil (feed does not report last modified)" do + it "doesn't update if timestamp is nil" do + timestamp = Time.zone.now.round feed = Feed.new(last_fetched: timestamp) - FeedRepository.update_last_fetched(feed, nil) + described_class.update_last_fetched(feed, nil) - expect(feed.last_fetched).to eq timestamp + expect(feed.last_fetched).to eq(timestamp) end it "doesn't update if timestamp is older than the current value" do + timestamp = Time.zone.now.round feed = Feed.new(last_fetched: timestamp) one_week_ago = timestamp - 1.week - FeedRepository.update_last_fetched(feed, one_week_ago) + described_class.update_last_fetched(feed, one_week_ago) - expect(feed.last_fetched).to eq timestamp + expect(feed.last_fetched).to eq(timestamp) end end - describe ".update_feed" do - it "saves the name and url" do - feed = Feed.new + describe ".delete" do + it "deletes the feed by id" do + feed = create(:feed) + + expect { described_class.delete(feed.id) }.to delete_record(feed) + end - FeedRepository.update_feed(feed, "Test Feed", "example.com/feed") + it "does not delete other feeds" do + feed1, feed2 = create_pair(:feed) - expect(feed.name).to eq "Test Feed" - expect(feed.url).to eq "example.com/feed" + expect { described_class.delete(feed1.id) }.not_to delete_record(feed2) end end - describe "fetch" do - let(:feed) { Feed.new(id: 1) } + describe ".list" do + it "returns all feeds ordered by name, case insensitive" do + feed1 = create(:feed, name: "foo") + feed2 = create(:feed, name: "Fabulous") + feed3 = create(:feed, name: "Zooby") + feed4 = create(:feed, name: "zabby") - it "finds by id" do - expect(Feed).to receive(:find).with(feed.id).and_return(feed) - FeedRepository.fetch(feed.id) + expect(described_class.list).to eq([feed2, feed1, feed4, feed3]) end + end + + describe ".in_group" do + it "returns feeds that are in a group" do + feed1 = create(:feed, group_id: 5) + feed2 = create(:feed, group_id: 6) - it "returns found feed" do - allow(Feed).to receive(:find).with(feed.id).and_return(feed) + expect(described_class.in_group).to contain_exactly(feed1, feed2) + end - result = FeedRepository.fetch(feed.id) + it "does not return feeds that are not in a group" do + create_pair(:feed) - expect(result).to eq feed + expect(described_class.in_group).to be_empty end end end diff --git a/spec/repositories/group_repository_spec.rb b/spec/repositories/group_repository_spec.rb new file mode 100644 index 000000000..82c86817b --- /dev/null +++ b/spec/repositories/group_repository_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe GroupRepository do + describe ".list" do + it "lists groups ordered by lower name" do + group1 = create(:group, name: "Zabba") + group2 = create(:group, name: "zlabba") + group3 = create(:group, name: "blabba") + group4 = create(:group, name: "Babba") + expected_groups = [group4, group3, group1, group2] + + expect(described_class.list).to eq(expected_groups) + end + end +end diff --git a/spec/repositories/story_repository_spec.rb b/spec/repositories/story_repository_spec.rb index b7eed53bf..c444da0af 100644 --- a/spec/repositories/story_repository_spec.rb +++ b/spec/repositories/story_repository_spec.rb @@ -1,106 +1,490 @@ -require "spec_helper" +# frozen_string_literal: true -app_require "repositories/story_repository" - -describe StoryRepository do +RSpec.describe StoryRepository do describe ".add" do - let(:feed) { double(url: "http://blog.golang.org/feed.atom") } - before do - allow(Story).to receive(:create) + def create_feed(url: "http://blog.golang.org/feed.atom") + double(url:) end it "normalizes story urls" do - entry = double(url: "//blog.golang.org/context", title: "", content: "").as_null_object - expect(StoryRepository).to receive(:normalize_url).with(entry.url, feed.url) + feed = create_feed + entry = double(url: "//blog.golang.org/context", title: "", content: "") + .as_null_object + + allow(Story).to receive(:create) + + expect(described_class) + .to receive(:normalize_url).with(entry.url, feed.url) - StoryRepository.add(entry, feed) + described_class.add(entry, feed) end - it "sanitizes titles" do + it "deletes line and paragraph separator characters from titles" do + feed = create_feed entry = double(title: "n\u2028\u2029", content: "").as_null_object - allow(StoryRepository).to receive(:normalize_url) + allow(described_class).to receive(:normalize_url) expect(Story).to receive(:create).with(hash_including(title: "n")) - StoryRepository.add(entry, feed) + described_class.add(entry, feed) + end + + it "deletes script tags from titles" do + feed = create_feed + entry = double(title: "n", content: "") + .as_null_object + allow(described_class).to receive(:normalize_url) + + expect(Story).to receive(:create).with(hash_including(title: "n")) + + described_class.add(entry, feed) + end + + it "sets the enclosure url when present" do + feed = create_feed + entry = instance_double( + Feedjira::Parser::ITunesRSSItem, + enclosure_url: "http://example.com/audio.mp3", + title: "", + summary: "", + content: "" + ).as_null_object + allow(described_class).to receive(:normalize_url) + + expect(Story).to receive(:create).with(hash_including(enclosure_url: "http://example.com/audio.mp3")) + + described_class.add(entry, feed) + end + + it "does not set the enclosure url when not present" do + feed = create_feed + entry = instance_double( + Feedjira::Parser::RSSEntry, + title: "", + summary: "", + content: "" + ).as_null_object + allow(described_class).to receive(:normalize_url) + + expect(Story).to receive(:create).with(hash_including(enclosure_url: nil)) + + described_class.add(entry, feed) + end + end + + describe ".fetch" do + it "finds the story by id" do + story = create(:story) + + expect(described_class.fetch(story.id)).to eq(story) + end + end + + describe ".fetch_by_ids" do + it "finds all stories by id" do + story1 = create(:story) + story2 = create(:story) + expected_stories = [story1, story2] + + actual_stories = described_class.fetch_by_ids(expected_stories.map(&:id)) + + expect(actual_stories).to match_array(expected_stories) + end + end + + describe ".fetch_unread_by_timestamp" do + it "returns unread stories from before the timestamp" do + story = create(:story, created_at: 1.week.ago, is_read: false) + + actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago) + + expect(actual_stories).to eq([story]) + end + + it "does not return unread stories from after the timestamp" do + create(:story, created_at: 3.days.ago, is_read: false) + + actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago) + + expect(actual_stories).to be_empty + end + + it "does not return read stories from before the timestamp" do + create(:story, created_at: 1.week.ago, is_read: true) + + actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago) + + expect(actual_stories).to be_empty + end + end + + describe ".fetch_unread_by_timestamp_and_group" do + it "returns unread stories before timestamp for group_id" do + feed = create(:feed, group_id: 52) + story = create(:story, feed:, created_at: 5.minutes.ago) + time = Time.zone.now + + stories = described_class.fetch_unread_by_timestamp_and_group(time, 52) + + expect(stories).to eq([story]) + end + + it "does not return read stories before timestamp for group_id" do + feed = create(:feed, group_id: 52) + create(:story, :read, feed:, created_at: 5.minutes.ago) + time = Time.zone.now + + stories = described_class.fetch_unread_by_timestamp_and_group(time, 52) + + expect(stories).to be_empty + end + + it "does not return unread stories after timestamp for group_id" do + feed = create(:feed, group_id: 52) + create(:story, feed:, created_at: 5.minutes.ago) + time = 6.minutes.ago + + stories = described_class.fetch_unread_by_timestamp_and_group(time, 52) + + expect(stories).to be_empty + end + + it "does not return stories before timestamp for other group_id" do + feed = create(:feed, group_id: 52) + create(:story, feed:, created_at: 5.minutes.ago) + time = Time.zone.now + + stories = described_class.fetch_unread_by_timestamp_and_group(time, 55) + + expect(stories).to be_empty + end + + it "does not return stories with no group_id before timestamp" do + feed = create(:feed) + create(:story, feed:, created_at: 5.minutes.ago) + time = Time.zone.now + + stories = described_class.fetch_unread_by_timestamp_and_group(time, 52) + + expect(stories).to be_empty + end + + it "returns unread stories before timestamp for nil group_id" do + feed = create(:feed) + story = create(:story, feed:, created_at: 5.minutes.ago) + time = Time.zone.now + + stories = described_class.fetch_unread_by_timestamp_and_group(time, nil) + + expect(stories).to eq([story]) + end + end + + describe ".fetch_unread_for_feed_by_timestamp" do + it "returns unread stories for the feed before timestamp" do + feed = create(:feed) + story = create(:story, feed:, created_at: 5.minutes.ago) + time = 4.minutes.ago + + stories = + described_class.fetch_unread_for_feed_by_timestamp(feed.id, time) + + expect(stories).to eq([story]) + end + + it "returns unread stories for the feed before string timestamp" do + feed = create(:feed) + story = create(:story, feed:, created_at: 5.minutes.ago) + timestamp = Integer(4.minutes.ago).to_s + + stories = + described_class.fetch_unread_for_feed_by_timestamp(feed.id, timestamp) + + expect(stories).to eq([story]) + end + + it "does not return read stories for the feed before timestamp" do + feed = create(:feed) + create(:story, :read, feed:, created_at: 5.minutes.ago) + time = 4.minutes.ago + + stories = + described_class.fetch_unread_for_feed_by_timestamp(feed.id, time) + + expect(stories).to be_empty + end + + it "does not return unread stories for the feed after timestamp" do + feed = create(:feed) + create(:story, feed:, created_at: 5.minutes.ago) + time = 6.minutes.ago + + stories = + described_class.fetch_unread_for_feed_by_timestamp(feed.id, time) + + expect(stories).to be_empty + end + + it "does not return unread stories for another feed before timestamp" do + feed = create(:feed) + create(:story, created_at: 5.minutes.ago) + time = 4.minutes.ago + + stories = + described_class.fetch_unread_for_feed_by_timestamp(feed.id, time) + + expect(stories).to be_empty + end + end + + describe ".unread" do + it "returns unread stories ordered by published date descending" do + story1 = create(:story, published: 5.minutes.ago) + story2 = create(:story, published: 4.minutes.ago) + + expect(described_class.unread).to eq([story2, story1]) + end + + it "does not return read stories" do + create(:story, :read, published: 5.minutes.ago) + create(:story, :read, published: 4.minutes.ago) + + expect(described_class.unread).to be_empty + end + + it "allows to override the order" do + story1 = create(:story, published: 5.minutes.ago) + story2 = create(:story, published: 4.minutes.ago) + + expect(described_class.unread(order: :asc)).to eq([story1, story2]) + end + end + + describe ".unread_since_id" do + it "returns unread stories with id greater than given id" do + story1 = create(:story) + story2 = create(:story) + + expect(described_class.unread_since_id(story1.id)).to eq([story2]) + end + + it "does not return read stories with id greater than given id" do + story1 = create(:story) + create(:story, :read) + + expect(described_class.unread_since_id(story1.id)).to be_empty + end + + it "does not return unread stories with id less than given id" do + create(:story) + story2 = create(:story) + + expect(described_class.unread_since_id(story2.id)).to be_empty + end + end + + describe ".feed" do + it "returns stories for the given feed id" do + feed = create(:feed) + story = create(:story, feed:) + + expect(described_class.feed(feed.id)).to eq([story]) + end + + it "sorts stories by published" do + feed = create(:feed) + story1 = create(:story, feed:, published: 1.day.ago) + story2 = create(:story, feed:, published: 1.hour.ago) + + expect(described_class.feed(feed.id)).to eq([story2, story1]) + end + + it "does not return stories for other feeds" do + feed = create(:feed) + create(:story) + + expect(described_class.feed(feed.id)).to be_empty + end + end + + describe ".read" do + it "returns read stories" do + story = create(:story, :read) + + expect(described_class.read).to eq([story]) + end + + it "sorts stories by published" do + story1 = create(:story, :read, published: 1.day.ago) + story2 = create(:story, :read, published: 1.hour.ago) + + expect(described_class.read).to eq([story2, story1]) + end + + it "does not return unread stories" do + create(:story) + + expect(described_class.read).to be_empty + end + + it "paginates results" do + stories = + 21.times.map { |num| create(:story, :read, published: num.days.ago) } + + expect(described_class.read).to eq(stories[0...20]) + expect(described_class.read(2)).to eq([stories.last]) + end + end + + describe ".starred" do + it "returns starred stories" do + story = create(:story, :starred) + + expect(described_class.starred).to eq([story]) + end + + it "sorts stories by published" do + story1 = create(:story, :starred, published: 1.day.ago) + story2 = create(:story, :starred, published: 1.hour.ago) + + expect(described_class.starred).to eq([story2, story1]) + end + + it "does not return unstarred stories" do + create(:story) + + expect(described_class.starred).to be_empty + end + + it "paginates results" do + stories = + 21.times.map { |num| create(:story, :starred, published: num.days.ago) } + + expect(described_class.starred).to eq(stories[0...20]) + expect(described_class.starred(2)).to eq([stories.last]) + end + end + + describe ".unstarred_read_stories_older_than" do + it "returns unstarred read stories older than given number of days" do + story = create(:story, :read, published: 6.days.ago) + + expect(described_class.unstarred_read_stories_older_than(5)) + .to eq([story]) + end + + it "does not return starred stories older than the given number of days" do + create(:story, :read, :starred, published: 6.days.ago) + + expect(described_class.unstarred_read_stories_older_than(5)).to be_empty + end + + it "does not return unread stories older than the given number of days" do + create(:story, published: 6.days.ago) + + expect(described_class.unstarred_read_stories_older_than(5)).to be_empty + end + + it "does not return stories newer than given number of days" do + create(:story, :read, published: 4.days.ago) + + expect(described_class.unstarred_read_stories_older_than(5)).to be_empty + end + end + + describe ".read_count" do + it "returns the count of read stories" do + create(:story, :read) + create(:story, :read) + create(:story, :read) + + expect(described_class.read_count).to eq(3) + end + + it "does not count unread stories" do + create_list(:story, 3) + + expect(described_class.read_count).to eq(0) end end describe ".extract_url" do it "returns the url" do feed = double(url: "http://github.com") - entry = double(url: "https://github.com/swanson/stringer") + entry = double(url: "https://github.com/stringer-rss/stringer") - expect(StoryRepository.extract_url(entry, feed)).to eq "https://github.com/swanson/stringer" + expect(described_class.extract_url(entry, feed)).to eq("https://github.com/stringer-rss/stringer") end it "returns the enclosure_url when the url is nil" do feed = double(url: "http://github.com") - entry = double(url: nil, enclosure_url: "https://github.com/swanson/stringer") + entry = double(url: nil, enclosure_url: "https://github.com/stringer-rss/stringer") - expect(StoryRepository.extract_url(entry, feed)).to eq "https://github.com/swanson/stringer" + expect(described_class.extract_url(entry, feed)).to eq("https://github.com/stringer-rss/stringer") end - end - describe ".extract_title" do - let(:entry) do + it "does not crash if url is nil but enclosure_url does not exist" do + feed = double(url: "http://github.com") + entry = double(url: nil) + + expect(described_class.extract_url(entry, feed)).to be_nil end + end + describe ".extract_title" do it "returns the title if there is a title" do entry = double(title: "title", summary: "summary") - expect(StoryRepository.extract_title(entry)).to eq "title" + expect(described_class.extract_title(entry)).to eq("title") end it "returns the summary if there isn't a title" do entry = double(title: "", summary: "summary") - expect(StoryRepository.extract_title(entry)).to eq "summary" + expect(described_class.extract_title(entry)).to eq("summary") end end describe ".extract_content" do - let(:entry) do - double(url: "http://mdswanson.com", - content: "Some test content") + it "sanitizes content" do + entry = double( + url: "http://mdswanson.com", + content: "Some test content" + ) + expect(described_class.extract_content(entry)).to eq("Some test content") end - let(:summary_only) do - double(url: "http://mdswanson.com", - content: nil, - summary: "Dumb publisher") - end + it "falls back to summary if there is no content" do + url = "http://mdswanson.com" + summary_only = double(url:, content: nil, summary: "Dumb publisher") - it "sanitizes content" do - expect(StoryRepository.extract_content(entry)).to eq "Some test content" + expect(described_class.extract_content(summary_only)) + .to eq("Dumb publisher") end - it "falls back to summary if there is no content" do - expect(StoryRepository.extract_content(summary_only)).to eq "Dumb publisher" + it "returns empty string if there is no content or summary" do + entry = double(url: "http://mdswanson.com", content: nil, summary: nil) + + expect(described_class.extract_content(entry)).to eq("") end - end - describe ".sanitize" do - context "regressions" do - it "handles tag properly" do - result = StoryRepository.sanitize("WM_ERROR asdf") - expect(result).to eq "WM_ERROR asdf" - end + it "expands urls" do + entry = double( + url: "http://mdswanson.com", + content: nil, + summary: "Page" + ) - it "handles
            tag properly" do - result = StoryRepository.sanitize("
            some code
            ") - expect(result).to eq "
            some code
            " - end + expect(described_class.extract_content(entry)) + .to eq("Page") + end - it "handles unprintable characters" do - result = StoryRepository.sanitize("n\u2028\u2029") - expect(result).to eq "n" - end + it "ignores URL expansion if entry url is nil" do + entry = + double(url: nil, content: nil, summary: "Page") - it "preserves line endings" do - result = StoryRepository.sanitize("test\r\ncase") - expect(result).to eq "test\r\ncase" - end + expect(described_class.extract_content(entry)) + .to eq("Page") end end end diff --git a/spec/repositories/user_repository_spec.rb b/spec/repositories/user_repository_spec.rb new file mode 100644 index 000000000..c919efcfd --- /dev/null +++ b/spec/repositories/user_repository_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe UserRepository do + describe ".fetch" do + it "returns nil when given id is nil" do + expect(described_class.fetch(nil)).to be_nil + end + + it "returns the user for the given id" do + user = default_user + + expect(described_class.fetch(user.id)).to eq(user) + end + end + + describe ".setup_complete?" do + it "returns false when there are no users" do + expect(described_class.setup_complete?).to be(false) + end + + it "returns true when there is at least one user" do + create(:user) + + expect(described_class.setup_complete?).to be(true) + end + end + + describe ".save" do + it "saves the given user" do + user = build(:user) + + expect { described_class.save(user) } + .to change(user, :persisted?).from(false).to(true) + end + + it "returns the given user" do + user = User.new + + expect(described_class.save(user)).to eq(user) + end + end + + describe ".first" do + it "returns the first user" do + user = default_user + create(:user) + + expect(described_class.first).to eq(user) + end + end +end diff --git a/spec/requests/debug_controller_spec.rb b/spec/requests/debug_controller_spec.rb new file mode 100644 index 000000000..196373dc1 --- /dev/null +++ b/spec/requests/debug_controller_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +RSpec.describe DebugController do + describe "#debug" do + def setup + login_as(create(:user, admin: true)) + + expect(MigrationStatus) + .to receive(:call) + .and_return(["Migration B - 2", "Migration C - 3"]) + end + + it "displays an admin settings link" do + setup + + get("/admin/debug") + + expect(rendered).to have_link("Admin Settings", href: settings_path) + end + + it "displays the current Ruby version" do + setup + + get "/admin/debug" + + expect(rendered).to have_css("dd", text: /#{RUBY_VERSION}/) + end + + it "displays the user agent" do + setup + + get("/admin/debug", headers: { "HTTP_USER_AGENT" => "testy" }) + + expect(rendered).to have_css("dd", text: /testy/) + end + + it "displays the jobs count" do + setup + 12.times { GoodJob::Job.create!(scheduled_at: Time.zone.now) } + + get "/admin/debug" + + expect(rendered).to have_css("dd", text: /12/) + end + + it "displays pending migrations" do + setup + + get "/admin/debug" + + expect(rendered).to have_css("li", text: /Migration B - 2/) + .and have_css("li", text: /Migration C - 3/) + end + end + + describe "#heroku" do + it "displays Heroku instructions" do + get("/heroku") + + expect(rendered).to have_text("add an hourly task") + end + end +end diff --git a/spec/requests/exports_controller_spec.rb b/spec/requests/exports_controller_spec.rb new file mode 100644 index 000000000..e83937798 --- /dev/null +++ b/spec/requests/exports_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe ExportsController do + describe "GET /feeds/export" do + def expected_xml + <<~XML + + + + Feeds from Stringer + + + + XML + end + + it "returns an OPML file" do + login_as(default_user) + + get "/feeds/export" + + expect(response.body).to eq(expected_xml) + end + + it "responds with xml content type" do + login_as(default_user) + + get "/feeds/export" + + expect(response.header["Content-Type"]).to include("application/xml") + end + + it "responds with disposition attachment" do + login_as(default_user) + + get "/feeds/export" + + expected = + "attachment; filename=\"stringer.opml\"; filename*=UTF-8''stringer.opml" + expect(response.header["Content-Disposition"]).to eq(expected) + end + end +end diff --git a/spec/requests/feeds_controller_spec.rb b/spec/requests/feeds_controller_spec.rb new file mode 100644 index 000000000..671395ee7 --- /dev/null +++ b/spec/requests/feeds_controller_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +RSpec.describe FeedsController do + include ActiveJob::TestHelper + + describe "#index" do + it "renders a list of feeds" do + login_as(default_user) + create_pair(:feed) + + get "/feeds" + + expect(rendered).to have_css("li.feed", count: 2) + end + + it "displays message to add feeds if there are none" do + login_as(default_user) + + get "/feeds" + + expect(rendered).to have_css("#add-some-feeds") + end + end + + describe "#show" do + it "displays a list of stories" do + login_as(default_user) + story = create(:story) + + get "/feed/#{story.feed_id}" + + expect(rendered).to have_css("#stories") + end + + it "raises an error if the feed belongs to another user" do + login_as(create(:user)) + feed = create(:feed) + + expect { get("/feed/#{feed.id}") } + .to raise_error(Authorization::NotAuthorizedError) + end + end + + describe "#edit" do + it "displays the feed edit form" do + login_as(default_user) + feed = create(:feed, name: "Rainbows/unicorns", url: "example.com/feed") + + get "/feeds/#{feed.id}/edit" + + expect(rendered).to have_field("feed_name", with: "Rainbows/unicorns") + end + end + + describe "#update" do + def params(feed, **overrides) + { + feed_id: feed.id, + feed_name: feed.name, + feed_url: feed.url, + group_id: feed.group_id, + **overrides + } + end + + it "updates a feed given the id" do + login_as(default_user) + feed = create(:feed, url: "example.com/atom", id: "12", group_id: nil) + + feed_url = "example.com/feed" + + expect { put("/feeds/#{feed.id}", params: params(feed, feed_url:)) } + .to change_record(feed, :url).to(feed_url) + end + + it "updates a feed group given the id" do + login_as(default_user) + feed = create(:feed, url: "example.com/atom") + + expect { put("/feeds/#{feed.id}", params: params(feed, group_id: 321)) } + .to change_record(feed, :group_id).to(321) + end + end + + describe "#destroy" do + it "deletes a feed given the id" do + login_as(default_user) + feed = create(:feed) + + expect { delete("/feeds/#{feed.id}") }.to delete_record(feed) + end + end + + describe "#new" do + it "displays a new feed form" do + login_as(default_user) + + get "/feeds/new" + + expect(rendered).to have_css("form#add-feed-setup") + end + end + + def with_test_adapter + adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + yield + + ActiveJob::Base.queue_adapter = adapter + end + + describe "#create" do + context "when the feed url is valid" do + feed_url = "http://example.com/" + + around { |example| with_test_adapter(&example) } + + it "adds the feed and queues it to be fetched" do + login_as(default_user) + stub_request(:get, feed_url).to_return(status: 200, body: "") + + expect { post("/feeds", params: { feed_url: }) } + .to change(Feed, :count).by(1) + end + + it "queues the feed to be fetched" do + login_as(default_user) + stub_request(:get, feed_url).to_return(status: 200, body: "") + + expect { post("/feeds", params: { feed_url: }) } + .to enqueue_job(CallableJob).with(Feed::FetchOne, instance_of(Feed)) + end + end + + context "when the feed url is invalid" do + feed_url = "http://not-a-valid-feed.com/" + + it "does not add the feed" do + login_as(default_user) + stub_request(:get, feed_url).to_return(status: 404) + post("/feeds", params: { feed_url: }) + + expect(rendered).to have_css(".error") + end + end + + context "when the feed url is one we already subscribe to" do + feed_url = "http://example.com/" + + it "does not add the feed" do + login_as(default_user) + create(:feed, url: feed_url) + stub_request(:get, feed_url).to_return(status: 200, body: "") + + post("/feeds", params: { feed_url: }) + + expect(rendered).to have_css(".error") + end + end + end +end diff --git a/spec/requests/fever_controller_spec.rb b/spec/requests/fever_controller_spec.rb new file mode 100644 index 000000000..2ad3ad2d7 --- /dev/null +++ b/spec/requests/fever_controller_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +RSpec.describe FeverController do + def standard_answer + { api_version: 3, auth: 1, last_refreshed_on_time: 0 } + end + + def cannot_auth + { api_version: 3, auth: 0 } + end + + def response_as_object + JSON.parse(response.body, symbolize_names: true) + end + + def params(user: default_user, **overrides) + { api_key: user.api_key, **overrides } + end + + describe "authentication" do + it "authenticates request with correct api_key" do + get("/fever", params:) + + expect(response_as_object).to include(standard_answer) + end + + it "does not authenticate request with incorrect api_key" do + get "/fever", params: params(api_key: "foo") + + expect(response_as_object).to include(cannot_auth) + end + + it "does not authenticate request when api_key is not provided" do + create(:user) + + get "/fever", params: params(api_key: nil) + + expect(response_as_object).to include(cannot_auth) + end + end + + describe "#get" do + it "returns standard answer" do + get("/fever", params:) + + expect(response_as_object).to include(standard_answer) + end + + context "when 'groups' header is provided" do + it "returns only ungrouped feedsin the ungrouped group" do + feed = create(:feed) + + get("/fever", params: params(groups: nil)) + + expect_groups_response([Group::UNGROUPED], [feed]) + end + + it "returns only grouped feeds in their respective groups" do + grouped_feed = create(:feed, :with_group) + + get("/fever", params: params(groups: nil)) + + groups = [Group::UNGROUPED, grouped_feed.group] + expect_groups_response(groups, [grouped_feed]) + end + + it "returns grouped and ungrouped feeds by groups" do + feed = create(:feed) + grouped_feed = create(:feed, :with_group) + + get("/fever", params: params(groups: nil)) + + groups = [Group::UNGROUPED, grouped_feed.group] + expect_groups_response(groups, [feed, grouped_feed]) + end + + def expect_groups_response(groups, feeds_groups) + feeds_groups = + feeds_groups.map do |feed| + { group_id: feed.group_id || 0, feed_ids: feed.id.to_s } + end + expect(response_as_object).to include(standard_answer) + .and include( + groups: match_array(groups.map(&:as_fever_json)), + feeds_groups: match_array(feeds_groups) + ) + end + end + + it "returns feeds and feeds by groups when 'feeds' header is provided" do + feed = create(:feed, :with_group) + + get("/fever", params: params(feeds: nil)) + + groups = [{ group_id: feed.group_id, feed_ids: feed.id.to_s }] + expect(response_as_object).to include(standard_answer) + .and include(feeds: [feed.as_fever_json], feeds_groups: groups) + end + + it "returns favicons hash when 'favicons' header provided" do + get("/fever", params: params(favicons: nil)) + + favicon = { id: 0, data: a_string_including("image/gif;base64") } + expect(response_as_object).to include(standard_answer) + .and include(favicons: [favicon]) + end + + it "returns stories when 'items' and 'since_id'" do + create(:story, id: 5) + story_two = create(:story, id: 6) + + get("/fever", params: params(items: nil, since_id: 5)) + + expect(response_as_object).to include(standard_answer) + .and include(items: [story_two.as_fever_json], total_items: 2) + end + + it "returns stories when 'items' header is provided without 'since_id'" do + stories = create_pair(:story) + + get("/fever", params: params(items: nil)) + + expect(response_as_object).to include(standard_answer) + .and include(items: stories.map(&:as_fever_json), total_items: 2) + end + + it "returns stories ids when 'items' and 'with_ids'" do + story = create(:story) + + get("/fever", params: params(items: nil, with_ids: story.id)) + + expect(response_as_object).to include(standard_answer) + .and include(items: [story.as_fever_json], total_items: 1) + end + + it "returns links as empty array when 'links' header is provided" do + get("/fever", params: params(links: nil)) + + expect(response_as_object).to include(standard_answer) + .and include(links: []) + end + + it "returns unread items ids when 'unread_item_ids' header is provided" do + stories = create_pair(:story) + + get("/fever", params: params(unread_item_ids: nil)) + + expect(response_as_object).to include(standard_answer) + .and include(unread_item_ids: stories.map(&:id).join(",")) + end + + it "returns starred items when 'saved_item_ids' header is provided" do + stories = create_pair(:story, :starred) + + get("/fever", params: params(saved_item_ids: nil)) + + expect(response_as_object).to include(standard_answer) + .and include(saved_item_ids: stories.map(&:id).join(",")) + end + end + + describe "#post" do + it "commands to mark story as read" do + story = create(:story) + + post("/fever", params: params(mark: "item", as: "read", id: story.id)) + + expect(response_as_object).to include(standard_answer) + end + + it "commands to mark story as unread" do + story = create(:story, :read) + + post("/fever", params: params(mark: "item", as: "unread", id: story.id)) + + expect(response_as_object).to include(standard_answer) + end + + it "commands to save story" do + story = create(:story) + + post("/fever", params: params(mark: "item", as: "saved", id: story.id)) + + expect(response_as_object).to include(standard_answer) + end + + it "commands to unsave story" do + story = create(:story, :starred) + + post("/fever", params: params(mark: "item", as: "unsaved", id: story.id)) + + expect(response_as_object).to include(standard_answer) + end + + it "commands to mark group as read" do + story = create(:story, :with_group, created_at: 1.week.ago) + before = Time.zone.now.to_i + id = story.feed.group_id + + post("/fever", params: params(mark: "group", as: "read", id:, before:)) + + expect(response_as_object).to include(standard_answer) + end + + it "commands to mark entire feed as read" do + story = create(:story, created_at: 1.week.ago) + before = Time.zone.now.to_i + params = params(mark: "feed", as: "read", id: story.feed_id, before:) + + expect { post("/fever", params:) } + .to change_record(story, :is_read).from(false).to(true) + end + + describe "#index" do + it "works with a trailing /" do + story = create(:story, created_at: 1.week.ago) + before = Time.zone.now.to_i + params = params(mark: "feed", as: "read", id: story.feed_id, before:) + + expect { get("/fever/", params:) } + .to change_record(story, :is_read).from(false).to(true) + end + end + + describe "#update" do + it "works with a trailing /" do + story = create(:story, created_at: 1.week.ago) + before = Time.zone.now.to_i + params = params(mark: "feed", as: "read", id: story.feed_id, before:) + + expect { post("/fever/", params:) } + .to change_record(story, :is_read).from(false).to(true) + end + end + end +end diff --git a/spec/requests/imports_controller_spec.rb b/spec/requests/imports_controller_spec.rb new file mode 100644 index 000000000..5fc561b59 --- /dev/null +++ b/spec/requests/imports_controller_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe ImportsController do + describe "GET /feeds/import" do + it "displays the import options" do + login_as(default_user) + + get "/feeds/import" + + expect(rendered).to have_field("opml_file") + end + end + + describe "POST /feeds/import" do + opml_file = Rack::Test::UploadedFile.new( + "spec/sample_data/subscriptions.xml", + "application/xml" + ) + + it "parses OPML and starts fetching" do + expect(Feed::ImportFromOpml).to receive(:call).once + login_as(default_user) + + post "/feeds/import", params: { opml_file: } + end + end +end diff --git a/spec/requests/passwords_controller_spec.rb b/spec/requests/passwords_controller_spec.rb new file mode 100644 index 000000000..d6ab2f2c2 --- /dev/null +++ b/spec/requests/passwords_controller_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.describe PasswordsController do + describe "#new" do + it "displays a form to enter your password" do + get "/setup/password" + + expect(rendered).to have_css("form#password_setup") + end + + it "redirects to the login page when signups are not enabled" do + create(:user) + Setting::UserSignup.update!(enabled: false) + + get "/setup/password" + + expect(response).to redirect_to(login_path) + end + end + + describe "#create" do + it "rejects empty passwords" do + post "/setup/password", + params: { user: { password: "", password_confirmation: "" } } + + expect(rendered).to have_text("Password can't be blank") + end + + it "rejects when password isn't confirmed" do + post "/setup/password", + params: { user: { password: "foo", password_confirmation: "bar" } } + + expect(rendered).to have_text("confirmation doesn't match") + end + + it "accepts confirmed passwords and redirects to next step" do + user_params = + { username: "foo", password: "foo", password_confirmation: "foo" } + + post "/setup/password", params: { user: user_params } + + expect(response).to redirect_to("/feeds/import") + end + + it "redirects to the login page when signups are not enabled" do + create(:user) + Setting::UserSignup.update!(enabled: false) + + post "/setup/password" + + expect(response).to redirect_to(login_path) + end + end + + describe "#update" do + def params(old_password, new_password) + { + user: { + password_challenge: old_password, + password: new_password, + password_confirmation: new_password + } + } + end + + context "when the password info is correct" do + it "updates the password" do + user = create(:user, password: "old_password") + login_as(user) + + put("/password", params: params("old_password", "new_password")) + + expect(user.reload.authenticate("new_password")).to eq(user) + end + + it "redirects to the news page" do + user = create(:user, password: "old_password") + login_as(user) + + put("/password", params: params("old_password", "new_password")) + + expect(response).to redirect_to("/news") + end + end + + context "when the password info is incorrect" do + it "displays an error message" do + user = create(:user, password: "old_password") + login_as(user) + + put("/password", params: params("wrong_password", "new_password")) + + expect(rendered).to have_text("Unable to update password") + end + + it "renders the edit profile page" do + user = create(:user, password: "old_password") + login_as(user) + + put("/password", params: params("wrong_password", "new_password")) + + expect(rendered).to have_text("Edit profile") + end + + it "doesn't update the password" do + user = create(:user, password: "old_password") + login_as(user) + + put("/password", params: params("wrong_password", "new_password")) + + expect(user.reload.authenticate("new_password")).to be_falsey + end + end + end +end diff --git a/spec/requests/profiles_controller_spec.rb b/spec/requests/profiles_controller_spec.rb new file mode 100644 index 000000000..182412f86 --- /dev/null +++ b/spec/requests/profiles_controller_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.describe ProfilesController do + describe "#edit" do + it "displays the edit view" do + login_as(default_user) + + get(edit_profile_path) + + expect(rendered).to have_text("Edit profile") + .and have_field("Username", with: default_user.username) + end + end + + describe "#update" do + context "when the user is valid" do + it "updates the user's username" do + login_as(default_user) + password_challenge = default_user.password + params = { user: { username: "new_username", password_challenge: } } + + expect { patch(profile_path, params:) } + .to change_record(default_user, :username).to("new_username") + end + + it "redirects to the news path" do + login_as(default_user) + password_challenge = default_user.password + params = { user: { username: "new_username", password_challenge: } } + + patch(profile_path, params:) + + expect(response).to redirect_to(news_path) + end + end + + context "when the username is invalid" do + it "displays an error message" do + login_as(default_user) + + patch(profile_path, params: { user: { username: "" } }) + + expect(rendered).to have_text("Username can't be blank") + end + + it "displays the edit view" do + login_as(default_user) + + patch(profile_path, params: { user: { username: "" } }) + + expect(rendered).to have_text("Edit profile") + end + + it "does not update the user's username" do + login_as(default_user) + + params = { user: { username: "" } } + + expect { patch(profile_path, params:) } + .not_to change_record(default_user, :username) + end + end + end +end diff --git a/spec/requests/sessions_controller_spec.rb b/spec/requests/sessions_controller_spec.rb new file mode 100644 index 000000000..876a1c8e0 --- /dev/null +++ b/spec/requests/sessions_controller_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe SessionsController do + describe "#new" do + it "has a password input and login button" do + create(:user) + + get "/login" + + expect(rendered).to have_field("password") + end + end + + describe "#create" do + it "denies access when password is incorrect" do + user = create(:user) + params = { username: user.username, password: "not-the-password" } + + post("/login", params:) + + expect(rendered).to have_css(".error") + end + + it "allows access when password is correct" do + user = default_user + params = { username: user.username, password: user.password } + + post("/login", params:) + + expect(session[:user_id]).to eq(user.id) + end + + it "redirects to the root page" do + user = default_user + params = { username: user.username, password: user.password } + + post("/login", params:) + + expect(URI.parse(response.location).path).to eq("/") + end + + it "redirects to the previous path when present" do + user = default_user + params = { username: user.username, password: user.password } + get("/archive") + + post("/login", params:) + + expect(URI.parse(response.location).path).to eq("/archive") + end + end + + describe "#destroy" do + it "clears the session" do + login_as(default_user) + + get "/logout" + + expect(session[:user_id]).to be_nil + end + + it "redirects to the root page" do + login_as(default_user) + + get "/logout" + + expect(URI.parse(response.location).path).to eq("/") + end + end +end diff --git a/spec/requests/settings_controller_spec.rb b/spec/requests/settings_controller_spec.rb new file mode 100644 index 000000000..87a1c819a --- /dev/null +++ b/spec/requests/settings_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe SettingsController do + describe "#index" do + it "displays the settings page" do + login_as(create(:user, admin: true)) + + get(settings_path) + + expect(rendered).to have_css("h1", text: "Settings") + .and have_text("User signups are disabled") + end + end + + describe "#update" do + it "allows enabling account creation" do + login_as(create(:user, admin: true)) + + params = { setting: { enabled: "true" } } + put(setting_path(Setting::UserSignup.first), params:) + + expect(Setting::UserSignup.enabled?).to be(true) + end + end +end diff --git a/spec/requests/stories_controller_spec.rb b/spec/requests/stories_controller_spec.rb new file mode 100644 index 000000000..210385dc8 --- /dev/null +++ b/spec/requests/stories_controller_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +RSpec.describe StoriesController do + describe "GET /news" do + it "redirects to the setup page when no user exists" do + get "/news" + + expect(URI.parse(response.location).path).to eq("/setup/password") + end + + it "redirects to the login page if not logged in" do + create(:user) + + get "/news" + + expect(URI.parse(response.location).path).to eq("/login") + end + + it "display list of unread stories" do + login_as(default_user) + create(:story) + + get "/news" + + expect(rendered).to have_css("#stories") + end + + it "displays the blog title and article title" do + login_as(default_user) + story = create(:story) + + get "/news" + + expect(rendered).to have_text(story.headline) + end + + it "displays all user actions" do + login_as(default_user) + create(:story) + + get "/news" + + expect(rendered).to have_css("#mark-all") + end + + it "has correct footer links" do + login_as(default_user) + create(:story) + + get "/news" + + expect(rendered).to have_link("Export").and have_link("Logout") + end + + it "displays a zen-like message when there are no unread stories" do + login_as(default_user) + + get "/news" + + expect(rendered).to have_css("#zen") + end + end + + describe "#archived" do + it "displays the list of read stories with pagination" do + login_as(default_user) + create(:story, :read) + + get "/archive" + + expect(rendered).to have_css("#stories") + end + end + + describe "#starred" do + it "displays the list of starred stories" do + login_as(default_user) + create(:story, :starred) + + get "/starred" + + expect(rendered).to have_css("#stories") + end + end + + describe "#update" do + headers = { "CONTENT_TYPE" => "application/json" } + + it "marks a story as read when it is_read not malformed" do + login_as(default_user) + story = create(:story) + params = { is_read: true }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :is_read).from(false).to(true) + end + + it "marks a story as read when is_read is malformed" do + login_as(default_user) + story = create(:story) + params = { is_read: "malformed" }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :is_read).from(false).to(true) + end + + it "marks a story as keep unread when it keep_unread not malformed" do + login_as(default_user) + story = create(:story) + params = { keep_unread: true }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :keep_unread).from(false).to(true) + end + + it "marks a story as keep unread when keep_unread is malformed" do + login_as(default_user) + story = create(:story) + params = { keep_unread: "malformed" }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :keep_unread).from(false).to(true) + end + + it "marks a story as starred when is_starred is not malformed" do + login_as(default_user) + story = create(:story) + params = { is_starred: true }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :is_starred).from(false).to(true) + end + + it "marks a story as starred when is_starred is malformed" do + login_as(default_user) + story = create(:story) + params = { is_starred: "malformed" }.to_json + + expect { put("/stories/#{story.id}", params:, headers:) } + .to change_record(story, :is_starred).from(false).to(true) + end + end + + describe "#mark_all_as_read" do + it "marks all unread stories as read and reload the page" do + login_as(default_user) + stories = create_pair(:story) + params = { story_ids: stories.map(&:id) } + + expect { post("/stories/mark_all_as_read", params:) } + .to change_all_records(stories, :is_read).from(false).to(true) + end + end +end diff --git a/spec/requests/tutorials_controller_spec.rb b/spec/requests/tutorials_controller_spec.rb new file mode 100644 index 000000000..f5cc80b1a --- /dev/null +++ b/spec/requests/tutorials_controller_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe TutorialsController do + describe "#index" do + context "when a user has not been setup" do + it "displays the tutorial and completes setup" do + login_as(default_user) + + get "/setup/tutorial" + + expect(rendered).to have_css("#mark-all-instruction") + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f2799081b..2783a10a9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,36 +1,74 @@ -ENV["RACK_ENV"] = "test" +# frozen_string_literal: true -require "rspec" -require "rspec-html-matchers" -require "rack/test" -require "pry" -require "faker" -require "ostruct" -require "date" +RSpec.configure do |config| + config.expect_with(:rspec) do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end -require "coveralls" -Coveralls.wear! + config.mock_with(:rspec) do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end -require "factories/feed_factory" -require "factories/story_factory" -require "factories/user_factory" -require "factories/group_factory" + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups -require "./app" + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching(:focus) -RSpec.configure do |config| - config.include Rack::Test::Methods - config.include RSpecHtmlMatchers -end + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" -def app_require(file) - require File.expand_path(File.join("app", file)) -end + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode + config.disable_monkey_patching! -def app - Stringer -end + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random -def session - last_request.env["rack.session"] + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand(config.seed) end diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb deleted file mode 100644 index 6e13deec9..000000000 --- a/spec/support/active_record.rb +++ /dev/null @@ -1,20 +0,0 @@ -require "active_record" - -db_config = YAML.load(File.read("config/database.yml")) -ActiveRecord::Base.establish_connection(db_config["test"]) -ActiveRecord::Base.logger = Logger.new("log/test.log") - -def need_to_migrate? - ActiveRecord::Migrator.new(:up, ActiveRecord::Migrator.migrations("db/migrate")).pending_migrations.any? -end - -ActiveRecord::Migrator.up "db/migrate" if need_to_migrate? - -RSpec.configure do |config| - config.around do |example| - ActiveRecord::Base.transaction do - example.run - raise ActiveRecord::Rollback - end - end -end diff --git a/spec/support/axe_core.rb b/spec/support/axe_core.rb new file mode 100644 index 000000000..42d48805d --- /dev/null +++ b/spec/support/axe_core.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "axe-rspec" + +module AccessibilityOverrides + A11Y_SKIP = [ + "aria-required-children", + "color-contrast", + "landmark-one-main", + "page-has-heading-one", + "region" + ].freeze + + def visit(*, accessible: true, a11y_skip: A11Y_SKIP) + page.visit(*) + + yield if block_given? + + check_accessibility(a11y_skip:) if accessible + end + + def click_on(*, a11y_skip: A11Y_SKIP, **) + page.click_on(*, **) + + yield if block_given? + + check_accessibility(a11y_skip:) + end + + def check_accessibility(a11y_skip:) + within_window(current_window) do + expect(page).to have_css("div") + expect(page).to be_axe_clean.skipping(*a11y_skip) + end + end +end + +RSpec.configure do |config| + config.include(AccessibilityOverrides, type: :system) +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..39cbb2b22 --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "capybara/rails" + +Capybara.enable_aria_label = true + +Selenium::WebDriver.logger.output = Rails.root.join("log/selenium.log") + +RSpec.configure do |config| + config.before(:each, type: :system) do + driven_by(:selenium, using: :firefox) do |driver| + driver.add_preference("browser.download.folderList", 2) + driver.add_preference("browser.download.manager.showWhenStarting", false) + driver.add_preference("browser.download.dir", Downloads::PATH.to_s) + driver.add_preference( + "browser.helperApps.neverAsk.saveToDisk", + "application/xml" + ) + end + end +end diff --git a/spec/support/coverage.rb b/spec/support/coverage.rb new file mode 100644 index 000000000..9f585690c --- /dev/null +++ b/spec/support/coverage.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +return if ENV["COVERAGE"] == "false" + +require "simplecov" + +if ENV["CI"] + require "coveralls" + SimpleCov.formatter = Coveralls::SimpleCov::Formatter +end + +SimpleCov.start(:rails) do + add_group("Commands", "app/commands") + add_group("Fever API", "app/fever_api") + add_group("Repositories", "app/repositories") + add_group("Tasks", "app/tasks") + add_group("Utils", "app/utils") + enable_coverage :branch +end +SimpleCov.minimum_coverage(line: 100, branch: 100) diff --git a/spec/support/downloads.rb b/spec/support/downloads.rb new file mode 100644 index 000000000..7dabe5b76 --- /dev/null +++ b/spec/support/downloads.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Downloads + PATH = Rails.root.join("tmp/downloads") + + class << self + def clear + FileUtils.rm_f(downloads) + end + + def content_for(page, filename) + page.document.synchronize(errors: [Errno::ENOENT]) do + File.read(PATH.join(filename)) + end + end + + private + + def downloads + Dir[PATH.join("*")] + end + end +end + +RSpec.configure do |config| + config.after(:each, type: :system) { Downloads.clear } +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 000000000..3d59d0734 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "factory_bot" +FactoryBot.find_definitions + +module FactoryCache + def self.user + @user ||= FactoryBot.create(:user) + end + + def self.reset + @user = nil + end +end + +RSpec.configure do |config| + config.include(FactoryBot::Syntax::Methods) + + config.after do + FactoryBot.rewind_sequences + FactoryCache.reset + end +end + +module FactoryBot::Syntax::Methods + def default_user + FactoryCache.user + end +end diff --git a/spec/support/feed_server.rb b/spec/support/feed_server.rb index 502c9b338..0fd60f524 100644 --- a/spec/support/feed_server.rb +++ b/spec/support/feed_server.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FeedServer attr_writer :response diff --git a/spec/support/files/subscriptions.xml b/spec/support/files/subscriptions.xml index bb44ada99..740faf24f 100755 --- a/spec/support/files/subscriptions.xml +++ b/spec/support/files/subscriptions.xml @@ -23,5 +23,9 @@ + + + + diff --git a/spec/support/generate_xml.rb b/spec/support/generate_xml.rb new file mode 100644 index 000000000..659903869 --- /dev/null +++ b/spec/support/generate_xml.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module GenerateXml + class << self + def call(feed, items) + build_feed(feed, items).to_xml + end + + private + + def build_feed(feed, items) + Nokogiri::XML::Builder.new do |xml| + xml.rss(version: "2.0") do + xml.title(feed.name) + xml.link(feed.url) + items.each { |item| build_item(xml, item) } + end + end + end + + def build_item(xml, item) + xml.item do + xml.title(item.title) + xml.link(item.url) + xml.pubDate(item.published) + xml.content(item.content) + end + end + end +end diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb new file mode 100644 index 000000000..27e32daa7 --- /dev/null +++ b/spec/support/matchers.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Matchers + def change_all_records(records, attribute) + Matchers::ChangeAllRecords.new(records, attribute) + end + + def change_record(record, attribute) + Matchers::ChangeRecord.new(record, attribute) + end + + def delete_record(record) + Matchers::DeleteRecord.new(record) + end + + def invoke(expected_method) + Matchers::Invoke.new(expected_method) + end +end + +RSpec.configure { |config| config.include(Matchers) } + +RSpec::Matchers.define_negated_matcher(:not_change, :change) +RSpec::Matchers.define_negated_matcher(:not_change_record, :change_record) +RSpec::Matchers.define_negated_matcher(:not_delete_record, :delete_record) +RSpec::Matchers.define_negated_matcher(:not_raise_error, :raise_error) + +Dir[File.join(__dir__, "./matchers/*.rb")].each { |path| require path } diff --git a/spec/support/matchers/change_all_records.rb b/spec/support/matchers/change_all_records.rb new file mode 100644 index 000000000..c53b6100a --- /dev/null +++ b/spec/support/matchers/change_all_records.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +class Matchers::ChangeAllRecords + include RSpec::Matchers::Composable + + attr_reader :records, + :attribute, + :original_values, + :new_values, + :expected_new_value, + :expected_original_value + + def initialize(records, attribute) + @records = records + @attribute = attribute + @expected_original_value = :__not_set__ + @expected_new_value = :__not_set__ + end + + def supports_block_expectations? + true + end + + def matches?(event_proc) + perform_change(event_proc) + + original_values_match? && changed? && new_values_match? + end + + def from(expected_original_value) + @expected_original_value = expected_original_value + self + end + + def does_not_match?(event_proc) + if new_values_expected? + message = "`expect { }.not_to change_all_records().to()` not supported" + + raise(NotImplementedError, message) + end + + perform_change(event_proc) + + original_values_match? && !changed? + end + + def to(expected_new_value) + @expected_new_value = expected_new_value + self + end + + def failure_message + if !original_values_match? + original_values_do_not_match_message + elsif !changed? + values_did_not_change_message + else + new_values_do_not_match_message + end + end + + def failure_message_when_negated + if original_values_match? # records changed + values_changed_message + else + original_values_do_not_match_message + end + end + + private + + def perform_change(event_proc) + @original_values = records.map { |record| record.public_send(attribute) } + + event_proc.call + + @new_values = records.map { |record| record.reload.public_send(attribute) } + end + + def original_values_do_not_match_message + <<~MESSAGE.squish + expected original values to be #{expected_original_value} but were + #{original_values} + MESSAGE + end + + def values_did_not_change_message + if new_values_expected? + <<~MESSAGE.squish + expected values to change from #{original_values} to + #{expected_new_value} but did not change + MESSAGE + else + <<~MESSAGE.squish + expected values to change from #{original_values} but did not change + MESSAGE + end + end + + def new_values_do_not_match_message + <<~MESSAGE.squish + expected values to change to #{expected_new_value} but changed to + #{new_values} + MESSAGE + end + + def values_changed_message + <<~MESSAGE.squish + expected values not to change from #{original_values} but changed to + #{new_values} + MESSAGE + end + + def original_values_match? + !original_value_expected? || original_values.all?(expected_original_value) + end + + def original_value_expected? + expected_original_value != :__not_set__ + end + + def changed? + new_values != original_values + end + + def new_values_match? + !new_values_expected? || new_values.all?(expected_new_value) + end + + def new_values_expected? + expected_new_value != :__not_set__ + end +end diff --git a/spec/support/matchers/change_record.rb b/spec/support/matchers/change_record.rb new file mode 100644 index 000000000..cc775aa2a --- /dev/null +++ b/spec/support/matchers/change_record.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +class Matchers::ChangeRecord + include RSpec::Matchers::Composable + + attr_reader :record, + :attribute, + :original_value, + :new_value, + :expected_new_value, + :expected_original_value + + def initialize(record, attribute) + @record = record + @attribute = attribute + @expected_original_value = :__not_set__ + @expected_new_value = :__not_set__ + end + + def supports_block_expectations? + true + end + + def matches?(event_proc) + perform_change(event_proc) + + original_value_matches? && changed? && new_value_matches? + end + + def from(expected_original_value) + @expected_original_value = expected_original_value + self + end + + def does_not_match?(event_proc) + if new_value_expected? + message = "`expect { }.not_to change_record().to()` is not supported" + + raise(NotImplementedError, message) + end + + perform_change(event_proc) + + original_value_matches? && !changed? + end + + def to(expected_new_value) + @expected_new_value = expected_new_value + self + end + + def failure_message + if !original_value_matches? + original_value_does_not_match_message + elsif !changed? + value_did_not_change_message + else # new value doesn't match + new_value_does_not_match_message + end + end + + def failure_message_when_negated + if original_value_matches? # record changed + value_changed_message + else + original_value_does_not_match_message + end + end + + private + + def perform_change(event_proc) + @original_value = record[attribute] + + event_proc.call + + @new_value = record.reload.public_send(attribute) + end + + def original_value_does_not_match_message + <<~MESSAGE.squish + expected original value to be #{expected_original_value} but was + #{original_value} + MESSAGE + end + + def value_did_not_change_message + if new_value_expected? + <<~MESSAGE.squish + expected value to change from #{original_value} to + #{expected_new_value} but did not change + MESSAGE + else + <<~MESSAGE.squish + expected value to change from #{original_value} but did not change + MESSAGE + end + end + + def new_value_does_not_match_message + <<~MESSAGE.squish + expected value to change to #{expected_new_value} but changed to + #{new_value} + MESSAGE + end + + def value_changed_message + <<~MESSAGE.squish + expected value not to change from #{original_value} but changed to + #{new_value} + MESSAGE + end + + def original_value_matches? + !original_value_expected? || original_value == expected_original_value + end + + def original_value_expected? + expected_original_value != :__not_set__ + end + + def changed? + new_value != original_value + end + + def new_value_matches? + !new_value_expected? || new_value == expected_new_value + end + + def new_value_expected? + expected_new_value != :__not_set__ + end +end diff --git a/spec/support/matchers/delete_record.rb b/spec/support/matchers/delete_record.rb new file mode 100644 index 000000000..4147ebc49 --- /dev/null +++ b/spec/support/matchers/delete_record.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class Matchers::DeleteRecord + include RSpec::Matchers::Composable + + attr_reader :record + + def initialize(record) + @record = record + end + + def supports_block_expectations? + true + end + + def matches?(event_proc) + perform_change(event_proc) + + exists_before? && !exists_after? + end + + def does_not_match?(event_proc) + perform_change(event_proc) + + exists_before? && exists_after? + end + + def failure_message + if exists_before? # was not deleted + existed_after_message + else + did_not_exist_before_message + end + end + + def failure_message_when_negated + if exists_before? # was deleted + did_not_exist_after_message + else + did_not_exist_before_message + end + end + + private + + def perform_change(event_proc) + @exists_before = record.class.exists?(record.id) + + event_proc.call + + @exists_after = record.class.exists?(record.id) + end + + def did_not_exist_before_message + <<~MESSAGE.squish + expected #{record.class.name} with id #{record.id} to originally exist, + but did not + MESSAGE + end + + def existed_after_message + <<~MESSAGE.squish + expected #{record.class.name} with id #{record.id} to be deleted, + but was not deleted + MESSAGE + end + + def did_not_exist_after_message + <<~MESSAGE.squish + expected #{record.class.name} with id #{record.id} not to be deleted, + but was deleted + MESSAGE + end + + def exists_before? + @exists_before + end + + def exists_after? + @exists_after + end +end diff --git a/spec/support/matchers/invoke.rb b/spec/support/matchers/invoke.rb new file mode 100644 index 000000000..2dbe8e4a8 --- /dev/null +++ b/spec/support/matchers/invoke.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/delegation" + +class Matchers::Invoke + include RSpec::Matchers::Composable + + delegate :failure_message, + :failure_message_when_negated, + to: :received_matcher + + def initialize(expected_method) + @expected_method = expected_method + end + + def matches?(event_proc) + raise(ArgumentError, "missing '.on'") unless defined?(@expected_recipient) + + allow(@expected_recipient).to receive(@expected_method) + allow(@expected_recipient).to receive_expected + + event_proc.call + received_matcher.matches?(@expected_recipient) + end + + def on(expected_recipient) + @expected_recipient = expected_recipient + self + end + + def with(*expected_arguments) + @expected_arguments = expected_arguments + self + end + + def and_return(*return_arguments) + @return_arguments = return_arguments + self + end + + def and_call_original + @and_call_original = true + self + end + + def twice + @times = 2 + self + end + + def once + @times = 1 + self + end + + def supports_block_expectations? + true + end + + private + + def receive_expected + receive_expected = receive(@expected_method) + + if defined?(@return_arguments) + receive_expected = receive_expected.and_return(*@return_arguments) + end + + if defined?(@and_call_original) + receive_expected = receive_expected.and_call_original + end + + receive_expected + end + + def allow(target) + RSpec::Mocks::AllowanceTarget.new(target) + end + + def receive(method_name) + RSpec::Mocks::Matchers::Receive.new(method_name, nil) + end + + def received_matcher + @received_matcher ||= + begin + matcher = RSpec::Mocks::Matchers::HaveReceived.new(@expected_method) + + if defined?(@expected_arguments) + matcher = matcher.with(*@expected_arguments) + end + + matcher = matcher.exactly(@times).times if defined?(@times) + + matcher + end + end +end diff --git a/spec/support/request_helpers.rb b/spec/support/request_helpers.rb new file mode 100644 index 000000000..fd25621cb --- /dev/null +++ b/spec/support/request_helpers.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module RequestHelpers + def login_as(user) + post("/login", params: { username: user.username, password: user.password }) + end + + def rendered + Capybara.string(response.body) + end +end diff --git a/spec/support/system_helpers.rb b/spec/support/system_helpers.rb new file mode 100644 index 000000000..a0ce6542d --- /dev/null +++ b/spec/support/system_helpers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SystemHelpers + def login_as(user) + visit(login_path) + fill_in("Username", with: user.username) + fill_in("Password", with: user.password) + click_on("Login") + end +end diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb new file mode 100644 index 000000000..4e0581096 --- /dev/null +++ b/spec/support/webmock.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "webmock/rspec" + +WebMock.disable_net_connect!( + allow_localhost: true, + allow: [/geckodriver/, /chromedriver/] +) diff --git a/spec/support/with_model.rb b/spec/support/with_model.rb new file mode 100644 index 000000000..f7a5f9ba7 --- /dev/null +++ b/spec/support/with_model.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class WithModel::Model + # Workaround for https://github.com/Casecommons/with_model/issues/35 + def cleanup_descendants_tracking + cache_classes = Rails.application.config.cache_classes + if defined?(ActiveSupport::DescendantsTracker) && !cache_classes + ActiveSupport::DescendantsTracker.clear([@model]) + elsif @model.superclass.respond_to?(:direct_descendants) + @model.superclass.subclasses.delete(@model) + end + end +end + +RSpec.configure { |config| config.extend(WithModel) } diff --git a/spec/system/account_setup_spec.rb b/spec/system/account_setup_spec.rb new file mode 100644 index 000000000..798874dc5 --- /dev/null +++ b/spec/system/account_setup_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe "account setup" do + def fill_in_fields(username:) + fill_in("Username", with: username) + fill_in("Password", with: "my-password") + fill_in("Confirm", with: "my-password") + click_on("Next") + end + + it "allows a user to sign up" do + visit "/" + + fill_in_fields(username: "my-username") + + expect(page).to have_text("Logged in as my-username") + end + + it "allows a second user to sign up" do + Setting::UserSignup.create!(enabled: true) + create(:user) + + visit "/" + + expect(page).to have_link("sign up") + end + + it "does not allow a second user to signup when not enabled" do + create(:user) + + visit "/" + + expect(page).to have_no_link("sign up") + end +end diff --git a/spec/system/application_settings_spec.rb b/spec/system/application_settings_spec.rb new file mode 100644 index 000000000..03019028c --- /dev/null +++ b/spec/system/application_settings_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe "application settings" do + it "allows enabling account creation" do + login_as(create(:user, admin: true)) + visit(settings_path) + + within("form", text: "User signups are disabled") { click_on("Enable") } + + expect(page).to have_content("User signups are enabled") + end +end diff --git a/spec/system/export_spec.rb b/spec/system/export_spec.rb new file mode 100644 index 000000000..4e1f4109a --- /dev/null +++ b/spec/system/export_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe "exporting feeds" do + it "allows exporting feeds" do + login_as(default_user) + feed = create(:feed, :with_group) + + click_on "Export" + + xml = Capybara.string(Downloads.content_for(page, "stringer.opml")) + expect(xml).to have_css("outline[title='#{feed.name}']") + end +end diff --git a/spec/system/feeds_index_spec.rb b/spec/system/feeds_index_spec.rb new file mode 100644 index 000000000..be664bd03 --- /dev/null +++ b/spec/system/feeds_index_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.describe "feeds/index" do + it "displays a list of feeds" do + login_as(default_user) + create_pair(:feed) + + visit "/feeds" + + expect(page).to have_css("li.feed", count: 2) + end + + it "displays message to add feeds if there are none" do + login_as(default_user) + + visit "/feeds" + + expect(page).to have_content("Hey, you should add some feeds") + end + + it "allows the user to delete a feed" do + login_as(default_user) + create(:feed) + + visit("/feeds") + click_on "Delete" + + expect(page).to have_content("Feed deleted") + end + + it "allows the user to edit a feed" do + login_as(default_user) + feed = create(:feed) + + visit "/feeds" + click_on "Edit" + + expect(page).to have_field("Feed Name", with: feed.name) + end + + it "links to the feed" do + login_as(default_user) + feed = create(:feed) + + visit "/feeds" + + expect(page).to have_link(href: feed.url) + end +end diff --git a/spec/system/good_job_spec.rb b/spec/system/good_job_spec.rb new file mode 100644 index 000000000..e39fe10b5 --- /dev/null +++ b/spec/system/good_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe "admin/good_job" do + it "displays the GoodJob dashboard" do + login_as(create(:user, admin: true)) + a11y_skip = [ + "aria-required-children", + "color-contrast", + "document-title", + "landmark-unique", + "landmark-one-main", + "page-has-heading-one", + "region" + ] + visit(good_job_path, a11y_skip:) + + expect(page).to have_link("Scheduled").and have_link("Queued") + end +end diff --git a/spec/system/import_spec.rb b/spec/system/import_spec.rb new file mode 100644 index 000000000..ccd85e2a0 --- /dev/null +++ b/spec/system/import_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe "importing feeds" do + it "allows importing feeds" do + login_as(default_user) + visit(feeds_import_path) + file_path = Rails.root.join("spec/fixtures/feeds.opml") + + attach_file("opml_file", file_path, visible: false) + + expect(page).to have_content("We're getting you some stories to read") + end +end diff --git a/spec/system/js_tests_spec.rb b/spec/system/js_tests_spec.rb new file mode 100644 index 000000000..1ff8910a1 --- /dev/null +++ b/spec/system/js_tests_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe "JS tests" do + it "passes the mocha tests" do + login_as(default_user) + + visit "/test" + + expect(page).to have_content("failures: 0") + end +end diff --git a/spec/system/profile_spec.rb b/spec/system/profile_spec.rb new file mode 100644 index 000000000..07e74c1ca --- /dev/null +++ b/spec/system/profile_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe "profile page" do + before do + login_as(default_user) + visit(edit_profile_path) + end + + it "allows the user to edit their username" do + fill_in_username_fields(default_user.password) + click_on("Update username") + + expect(page).to have_text("Logged in as new_username") + end + + def fill_in_username_fields(existing_password) + within_fieldset("Change Username") do + fill_in("Username", with: "new_username") + fill_in("Existing password", with: existing_password) + end + end + + it "allows the user to edit their password" do + fill_in_password_fields(default_user.password, "new_password") + click_on("Update password") + + expect(page).to have_text("Password updated") + end + + def fill_in_password_fields(existing_password, new_password) + within_fieldset("Change Password") do + fill_in("Existing password", with: existing_password) + fill_in("New password", with: new_password) + fill_in("Password confirmation", with: new_password) + end + end + + it "allows the user to edit their feed order" do + select("Oldest first", from: "Stories feed order") + click_on("Update") + + expect(default_user.reload).to be_stories_order_asc + end +end diff --git a/spec/system/stories_index_spec.rb b/spec/system/stories_index_spec.rb new file mode 100644 index 000000000..6156df6ce --- /dev/null +++ b/spec/system/stories_index_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +RSpec.describe "stories/index" do + it "displays the stories" do + create(:story, title: "My Story") + login_as(default_user) + + visit news_path + + expect(page).to have_content("My Story") + end + + it "does not display read stories" do + create(:story, :read, title: "My Story") + login_as(default_user) + + visit news_path + + expect(page).to have_no_content("My Story") + end + + it "marks all stories as read" do + create(:story, title: "My Story") + login_as(default_user) + visit news_path + + click_on "Mark all as read" + + expect(page).to have_content("You've reached RSS Zero") + end + + it "refreshes the page" do + login_as(default_user) + visit news_path + create(:story, title: "My Story") + + click_on "Refresh" + + expect(page).to have_content("My Story") + end + + it "displays the story body when the row is clicked" do + create(:story, title: "My Story", body: "My Body") + login_as(default_user) + visit news_path + + find(".story-preview", text: "My Story").click + + expect(page).to have_content("My Body") + end + + def star_story(story_title) + visit(news_path) + + find(".story-preview", text: story_title).click + find(".story-actions .story-starred").click + end + + it "allows marking a story as starred" do + create(:story, title: "My Story") + login_as(default_user) + + star_story("My Story") + + visit(starred_path) + expect(page).to have_content("My Story") + end + + it "allows marking a story as unstarred" do + create(:story, :starred, title: "My Story") + login_as(default_user) + + star_story("My Story") + + visit(starred_path) + expect(page).to have_no_content("My Story") + end + + def mark_story_unread(story_title) + visit(news_path) + + find(".story-preview", text: story_title).click + find(".story-actions .story-keep-unread").click + end + + it "allows marking a story as unread" do + create(:story, :starred, title: "My Story") + login_as(default_user) + mark_story_unread("My Story") + + visit(news_path) + + expect(page).to have_content("My Story") + end + + it "allows viewing a story with hot keys" do + create(:story, title: "My Story", body: "My Body") + login_as(default_user) + visit news_path + + send_keys("j") + + expect(page).to have_content("My Body") + end +end diff --git a/spec/tasks/change_password_spec.rb b/spec/tasks/change_password_spec.rb deleted file mode 100644 index f39dfcc11..000000000 --- a/spec/tasks/change_password_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "spec_helper" - -app_require "tasks/change_password" - -describe ChangePassword do - let(:command) { double("command") } - let(:new_password) { "new-pw" } - - let(:task) { ChangePassword.new(command) } - - describe "#change_password" do - it "invokes command with confirmed password" do - expect(task).to receive(:ask_hidden).twice - .and_return(new_password, new_password) - - expect(command) - .to receive(:change_user_password) - .with(new_password) - - task.change_password - end - - it "repeats until a matching confirmation" do - expect(task).to receive(:ask_hidden).exactly(2).times - .and_return(new_password, "", new_password, new_password) - - expect(command) - .to receive(:change_user_password) - .with(new_password) - - task.change_password - end - end -end diff --git a/spec/tasks/fetch_feed_spec.rb b/spec/tasks/fetch_feed_spec.rb deleted file mode 100644 index 7408e854e..000000000 --- a/spec/tasks/fetch_feed_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -require "spec_helper" -app_require "tasks/fetch_feed" - -describe FetchFeed do - describe "#fetch" do - let(:daring_fireball) do - double(id: 1, - url: "http://daringfireball.com/feed", - last_fetched: Time.new(2013, 1, 1), - stories: []) - end - - before do - allow(StoryRepository).to receive(:add) - allow(FeedRepository).to receive(:update_last_fetched) - allow(FeedRepository).to receive(:set_status) - end - - context "when feed has not been modified" do - it "should not try to fetch posts" do - parser = double(fetch_and_parse: 304) - - expect(StoryRepository).not_to receive(:add) - - FetchFeed.new(daring_fireball, parser: parser) - end - end - - context "when no new posts have been added" do - it "should not add any new posts" do - fake_feed = double(last_modified: Time.new(2012, 12, 31)) - parser = double(fetch_and_parse: fake_feed) - - allow_any_instance_of(FindNewStories).to receive(:new_stories).and_return([]) - - expect(StoryRepository).not_to receive(:add) - - FetchFeed.new(daring_fireball, parser: parser).fetch - end - end - - context "when new posts have been added" do - let(:now) { Time.now } - let(:new_story) { double } - let(:old_story) { double } - - let(:fake_feed) { double(last_modified: now, entries: [new_story, old_story]) } - let(:fake_parser) { double(fetch_and_parse: fake_feed) } - - before { allow_any_instance_of(FindNewStories).to receive(:new_stories).and_return([new_story]) } - - it "should only add posts that are new" do - expect(StoryRepository).to receive(:add).with(new_story, daring_fireball) - expect(StoryRepository).not_to receive(:add).with(old_story, daring_fireball) - - FetchFeed.new(daring_fireball, parser: fake_parser).fetch - end - - it "should update the last fetched time for the feed" do - expect(FeedRepository).to receive(:update_last_fetched) - .with(daring_fireball, now) - - FetchFeed.new(daring_fireball, parser: fake_parser).fetch - end - end - - context "feed status" do - it "sets the status to green if things are all good" do - fake_feed = double(last_modified: Time.new(2012, 12, 31), entries: []) - parser = double(fetch_and_parse: fake_feed) - - expect(FeedRepository).to receive(:set_status) - .with(:green, daring_fireball) - - FetchFeed.new(daring_fireball, parser: parser).fetch - end - - it "sets the status to red if things go wrong" do - parser = double(fetch_and_parse: 404) - - expect(FeedRepository).to receive(:set_status) - .with(:red, daring_fireball) - - FetchFeed.new(daring_fireball, parser: parser).fetch - end - end - end -end diff --git a/spec/tasks/fetch_feeds_spec.rb b/spec/tasks/fetch_feeds_spec.rb deleted file mode 100644 index 80ad429ac..000000000 --- a/spec/tasks/fetch_feeds_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "spec_helper" - -describe FetchFeeds do - describe "#fetch_all" do - let(:feeds) { [FeedFactory.build, FeedFactory.build] } - let(:fetcher_one) { double } - let(:fetcher_two) { double } - let(:pool) { double } - - it "calls FetchFeed#fetch for every feed" do - allow(pool).to receive(:process).and_yield - allow(FetchFeed).to receive(:new).and_return(fetcher_one, fetcher_two) - - expect(fetcher_one).to receive(:fetch).once - expect(fetcher_two).to receive(:fetch).once - - expect(pool).to receive(:shutdown) - - FetchFeeds.new(feeds, pool).fetch_all - end - end -end diff --git a/spec/tasks/remove_old_stories_spec.rb b/spec/tasks/remove_old_stories_spec.rb index 23271155e..f73bb8394 100644 --- a/spec/tasks/remove_old_stories_spec.rb +++ b/spec/tasks/remove_old_stories_spec.rb @@ -1,52 +1,50 @@ -require "spec_helper" -app_require "tasks/remove_old_stories" +# frozen_string_literal: true -describe RemoveOldStories do - describe ".remove!" do - let(:stories_mock) do - stories = double("stories") - allow(stories).to receive(:delete_all) - stories - end +RSpec.describe RemoveOldStories do + def create_stories + stories = double("stories") + allow(stories).to receive(:delete_all) + stories + end - it "should pass along the number of days to the story repository query" do - allow(RemoveOldStories).to receive(:pruned_feeds) { [] } + it "passes along the number of days to the story repository query" do + allow(described_class).to receive(:pruned_feeds).and_return([]) - expect(StoryRepository).to receive(:unstarred_read_stories_older_than).with(7).and_return(stories_mock) + expect(StoryRepository).to receive(:unstarred_read_stories_older_than) + .with(7).and_return(create_stories) - RemoveOldStories.remove!(7) - end + described_class.call(7) + end - it "should request deletion of all old stories" do - allow(RemoveOldStories).to receive(:pruned_feeds) { [] } - allow(StoryRepository).to receive(:unstarred_read_stories_older_than) { stories_mock } + it "requests deletion of all old stories" do + allow(described_class).to receive(:pruned_feeds).and_return([]) + mocked_stories = create_stories + allow(StoryRepository) + .to receive(:unstarred_read_stories_older_than) { mocked_stories } - expect(stories_mock).to receive(:delete_all) + expect(mocked_stories).to receive(:delete_all) - RemoveOldStories.remove!(11) - end + described_class.call(11) + end - it "should fetch affected feeds by id" do - allow(RemoveOldStories).to receive(:old_stories) do - stories = [double("story", feed_id: 3), double("story", feed_id: 5)] - allow(stories).to receive(:delete_all) - stories - end + it "fetches affected feeds by id" do + stories = create_list(:story, 3, :read, created_at: 1.month.ago) - expect(FeedRepository).to receive(:fetch_by_ids).with([3, 5]).and_return([]) + described_class.call(13) - RemoveOldStories.remove!(13) - end + expect(Story.where(id: stories.map(&:id))).to be_empty + end - it "should update last_fetched on affected feeds" do - feeds = [double("feed a"), double("feed b")] - allow(RemoveOldStories).to receive(:pruned_feeds) { feeds } - allow(RemoveOldStories).to receive(:old_stories) { stories_mock } + it "updates last_fetched on affected feeds" do + feeds = [double("feed a"), double("feed b")] + allow(described_class).to receive(:pruned_feeds) { feeds } + allow(described_class).to receive(:old_stories) { create_stories } - expect(FeedRepository).to receive(:update_last_fetched).with(feeds.first, anything) - expect(FeedRepository).to receive(:update_last_fetched).with(feeds.last, anything) + expect(FeedRepository) + .to receive(:update_last_fetched).with(feeds.first, anything) + expect(FeedRepository) + .to receive(:update_last_fetched).with(feeds.last, anything) - RemoveOldStories.remove!(13) - end + described_class.call(13) end end diff --git a/spec/utils/authorization_spec.rb b/spec/utils/authorization_spec.rb new file mode 100644 index 000000000..80c98aa9e --- /dev/null +++ b/spec/utils/authorization_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe Authorization do + describe "#check" do + it "raises an error if the record belongs to another user" do + user = create(:user) + feed = create(:feed) + + expect { described_class.new(user).check(feed) } + .to raise_error(Authorization::NotAuthorizedError) + end + + describe "when the user owns the record" do + it "marks the request as authorized" do + feed = create(:feed) + authorization = described_class.new(feed.user) + + authorization.check(feed) + + expect(authorization).to be_authorized + end + + it "returns the record" do + feed = create(:feed) + authorization = described_class.new(feed.user) + + expect(authorization.check(feed)).to eq(feed) + end + end + end + + describe "#scope" do + it "returns the records that belong to the user" do + feed = create(:feed) + + expect(described_class.new(feed.user).scope(Feed)).to eq([feed]) + end + + it "does not return records that belong to another user" do + create(:feed, user: create(:user)) + + expect(described_class.new(default_user).scope(Feed)).to be_empty + end + + it "marks the request as authorized" do + authorization = described_class.new(default_user) + + authorization.scope(Feed) + + expect(authorization).to be_authorized + end + end + + describe "#skip" do + it "marks the request as authorized" do + authorization = described_class.new(default_user) + + authorization.skip + + expect(authorization).to be_authorized + end + end + + describe "#verify" do + it "raises an error if the request was not authorized" do + authorization = described_class.new(default_user) + + expect { authorization.verify } + .to raise_error(Authorization::NotAuthorizedError) + end + + it "does not raise an error if the request was authorized" do + authorization = described_class.new(default_user) + authorization.skip + + expect { authorization.verify }.not_to raise_error + end + end +end diff --git a/spec/utils/content_sanitizer_spec.rb b/spec/utils/content_sanitizer_spec.rb new file mode 100644 index 000000000..e8ab99d32 --- /dev/null +++ b/spec/utils/content_sanitizer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe ContentSanitizer do + it "handles tag properly" do + result = described_class.call("WM_ERROR asdf") + + expect(result).to eq("WM_ERROR asdf") + end + + it "handles
            tag properly" do + result = described_class.call("
            some code
            ") + + expect(result).to eq("
            some code
            ") + end + + it "handles unprintable characters" do + result = described_class.call("n\u2028\u2029") + + expect(result).to eq("n") + end + + it "preserves line endings" do + result = described_class.call("test\r\ncase") + + expect(result).to eq("test\r\ncase") + end +end diff --git a/spec/utils/feed_discovery_spec.rb b/spec/utils/feed_discovery_spec.rb index 1c10286d9..4f5ddcd4e 100644 --- a/spec/utils/feed_discovery_spec.rb +++ b/spec/utils/feed_discovery_spec.rb @@ -1,52 +1,56 @@ -require "spec_helper" +# frozen_string_literal: true -app_require "utils/feed_discovery" +RSpec.describe FeedDiscovery do + url = "http://example.com" + invalid_discovered_url = "http://not-a-valid-feed.com" + valid_discovered_url = "http://a-valid-feed.com" -describe FeedDiscovery do - let(:finder) { double } - let(:parser) { double } - let(:feed) { double(feed_url: url) } - let(:url) { "http://example.com" } + it "returns false if url is not a feed and feed url cannot be discovered" do + expect(HTTParty).to receive(:get).with(url) + expect(Feedjira).to receive(:parse).and_raise(StandardError) + expect(Feedbag).to receive(:find).and_return([]) - let(:invalid_discovered_url) { "http://not-a-valid-feed.com" } - let(:valid_discovered_url) { "http://a-valid-feed.com" } + result = described_class.call(url) - describe "#discover" do - it "returns false if url is not a feed and feed url cannot be discovered" do - expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError) - expect(finder).to receive(:find).and_return([]) + expect(result).to be(false) + end + + it "returns a feed if the url provided is parsable" do + feed = double(feed_url: url) + expect(HTTParty).to receive(:get).with(url) + expect(Feedjira).to receive(:parse).and_return(feed) - result = FeedDiscovery.new.discover(url, finder, parser) + result = described_class.call(url) - expect(result).to eq(false) - end + expect(result).to eq(feed) + end - it "returns a feed if the url provided is parsable" do - expect(parser).to receive(:fetch_and_parse).with(url).and_return(feed) + it "returns false if the discovered feed is not parsable" do + expect(HTTParty).to receive(:get).with(url) + expect(Feedjira).to receive(:parse).and_raise(StandardError) - result = FeedDiscovery.new.discover(url, finder, parser) + expect(Feedbag).to receive(:find).and_return([invalid_discovered_url]) - expect(result).to eq feed - end + expect(HTTParty).to receive(:get).with(invalid_discovered_url) + expect(Feedjira).to receive(:parse).and_raise(StandardError) - it "returns false if the discovered feed is not parsable" do - expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError) - expect(finder).to receive(:find).and_return([invalid_discovered_url]) - expect(parser).to receive(:fetch_and_parse).with(invalid_discovered_url).and_raise(StandardError) + result = described_class.call(url) + + expect(result).to be(false) + end - result = FeedDiscovery.new.discover(url, finder, parser) + it "returns the feed if the discovered feed is parsable" do + feed = double(feed_url: url) + expect(HTTParty).to receive(:get).with(url) + expect(Feedjira).to receive(:parse).and_raise(StandardError) - expect(result).to eq(false) - end + expect(Feedbag).to receive(:find).and_return([valid_discovered_url]) - it "returns the feed if the discovered feed is parsable" do - expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError) - expect(finder).to receive(:find).and_return([valid_discovered_url]) - expect(parser).to receive(:fetch_and_parse).with(valid_discovered_url).and_return(feed) + expect(HTTParty).to receive(:get).with(valid_discovered_url) + expect(Feedjira).to receive(:parse).and_return(feed) - result = FeedDiscovery.new.discover(url, finder, parser) + result = described_class.call(url) - expect(result).to eq feed - end + expect(result).to eq(feed) end end diff --git a/spec/utils/i18n_support_spec.rb b/spec/utils/i18n_support_spec.rb index 861f1f713..89d3ec70e 100644 --- a/spec/utils/i18n_support_spec.rb +++ b/spec/utils/i18n_support_spec.rb @@ -1,35 +1,21 @@ -require "spec_helper" +# frozen_string_literal: true -describe "i18n" do - before do +RSpec.describe "i18n", type: :request do + it "loads default locale when no locale was set" do allow(UserRepository).to receive(:setup_complete?).and_return(false) - ENV["LOCALE"] = locale + ENV["LOCALE"] = nil get "/" - end - - context "when no locale was set" do - let(:locale) { nil } - it "should load default locale" do - expect(I18n.locale.to_s).to eq "en" - expect(I18n.locale.to_s).not_to eq nil - end + expect(I18n.locale.to_s).to eq("en") + expect(I18n.locale.to_s).not_to be_nil end - context "when locale was set" do - let(:locale) { "en" } - - it "should load default locale" do - expect(I18n.locale.to_s).to eq "en" - expect(I18n.t("layout.title")).to eq "stringer | your rss buddy" - end - end - - context "when a missing locale was set" do - let(:locale) { "xx" } + it "loads default locale was locale was set" do + allow(UserRepository).to receive(:setup_complete?).and_return(false) + ENV["LOCALE"] = "en" + get "/" - it "should not find localization strings" do - expect(I18n.t("layout.title", locale: ENV["LOCALE"].to_sym)).not_to eq "stringer | your rss buddy" - end + expect(I18n.locale.to_s).to eq("en") + expect(I18n.t("layout.title")).to eq("stringer | your rss buddy") end end diff --git a/spec/utils/opml_parser_spec.rb b/spec/utils/opml_parser_spec.rb index e007dc195..ce274d522 100644 --- a/spec/utils/opml_parser_spec.rb +++ b/spec/utils/opml_parser_spec.rb @@ -1,92 +1,86 @@ -require "spec_helper" +# frozen_string_literal: true -app_require "utils/opml_parser" +RSpec.describe OpmlParser do + it "returns a hash of feed details from an OPML file" do + result = described_class.call(<<-XML) + + + + matt swanson subscriptions in Google Reader + + + + + + + XML -describe OpmlParser do - let(:parser) { OpmlParser.new } + resulted_values = result.values.flatten + expect(resulted_values.size).to eq(2) + expect(resulted_values.first[:name]).to eq("a sample feed") + expect(resulted_values.first[:url]).to eq("http://feeds.feedburner.com/foobar") - describe "#parse_feeds" do - it "it returns a hash of feed details from an OPML file" do - result = parser.parse_feeds(<<-eos) - - - - matt swanson subscriptions in Google Reader - - + expect(resulted_values.last[:name]).to eq("Matt's Blog") + expect(resulted_values.last[:url]).to eq("http://mdswanson.com/atom.xml") + expect(result.keys.first).to eq("Ungrouped") + end + + it "handles nested groups of feeds" do + result = described_class.call(<<-XML) + + + + matt swanson subscriptions in Google Reader + + + - - - - eos - - resulted_values = result.values.flatten - expect(resulted_values.size).to eq 2 - expect(resulted_values.first[:name]).to eq "a sample feed" - expect(resulted_values.first[:url]).to eq "http://feeds.feedburner.com/foobar" - - expect(resulted_values.last[:name]).to eq "Matt's Blog" - expect(resulted_values.last[:url]).to eq "http://mdswanson.com/atom.xml" - expect(result.keys.first).to eq "Ungrouped" - end + + + + XML + resulted_values = result.values.flatten - it "handles nested groups of feeds" do - result = parser.parse_feeds(<<-eos) - - - - matt swanson subscriptions in Google Reader - - - - - - - - eos - resulted_values = result.values.flatten - - expect(resulted_values.count).to eq 1 - expect(resulted_values.first[:name]).to eq "a sample feed" - expect(resulted_values.first[:url]).to eq "http://feeds.feedburner.com/foobar" - expect(result.keys.first).to eq "Technology News" - end + expect(resulted_values.count).to eq(1) + expect(resulted_values.first[:name]).to eq("a sample feed") + expect(resulted_values.first[:url]).to eq("http://feeds.feedburner.com/foobar") + expect(result.keys.first).to eq("Technology News") + end - it "doesn't explode when there are no feeds" do - result = parser.parse_feeds(<<-eos) - - - - matt swanson subscriptions in Google Reader - - - - - eos + it "doesn't explode when there are no feeds" do + result = described_class.call(<<-XML) + + + + matt swanson subscriptions in Google Reader + + + + + XML - expect(result).to be_empty - end + expect(result).to be_empty + end - it "handles Feedly's exported OPML (missing :title)" do - result = parser.parse_feeds(<<-eos) - - - - My feeds (Feedly) - - - - - - eos - resulted_values = result.values.flatten + it "handles Feedly's exported OPML (missing :title)" do + result = described_class.call(<<-XML) + + + + My feeds (Feedly) + + + + + + XML + resulted_values = result.values.flatten - expect(resulted_values.count).to eq 1 - expect(resulted_values.first[:name]).to eq "a sample feed" - end + expect(resulted_values.count).to eq(1) + expect(resulted_values.first[:name]).to eq("a sample feed") end end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/backbone-min.js b/vendor/assets/javascripts/backbone-min.js similarity index 99% rename from app/assets/javascripts/backbone-min.js rename to vendor/assets/javascripts/backbone-min.js index 3541019c5..bce4fbc1b 100644 --- a/app/assets/javascripts/backbone-min.js +++ b/vendor/assets/javascripts/backbone-min.js @@ -1,4 +1 @@ (function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('