diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..53c681faa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.dockerignore +.env +boilerplate/node_modules/ +boilerplate/vendor/bundle/ +boilerplate/tmp/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..64e179145 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +*.rbc +capybara-*.html +.rspec +/db/*.sqlite3 +/db/*.sqlite3-journal +/db/*.sqlite3-[0-9]* +/public/system +/coverage/ +/spec/tmp +*.orig +rerun.txt +pickle-email-*.html + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# TODO Comment out this rule if you are OK with secrets being uploaded to the repo +config/initializers/secret_token.rb +config/master.key + +# Only include if you have production secrets in this file, which is no longer a Rails default +# config/secrets.yml + +# dotenv +# TODO Comment out this rule if environment variables can be committed +.env + +## Environment normalization: +/.bundle +/vendor/bundle + +# these should all be checked in to normalize the environment: +# Gemfile.lock, .ruby-version, .ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# if using bower-rails ignore default bower_components path bower.json files +/vendor/assets/bower_components +*.bowerrc +bower.json + +# Ignore pow environment settings +.powenv + +# Ignore Byebug command history file. +.byebug_history + +# Ignore node_modules +node_modules/ + +# Ignore precompiled javascript packs +/public/packs +/public/packs-test +/public/assets + +# Ignore yarn files +/yarn-error.log +yarn-debug.log* +.yarn-integrity + +# Ignore uploaded files in development +/storage/* +!/storage/.keep +.irb_history diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..9b1a0b9fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Dockerfile development version +FROM ruby:latest AS source-development + +# Install yarn +# RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg -o /root/yarn-pubkey.gpg && apt-key add /root/yarn-pubkey.gpg +# RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list +# RUN apt-get update && apt-get install -y --no-install-recommends nodejs yarn + +ARG UID +ARG GID + +# Default directory +ENV INSTALL_PATH /opt/app +RUN mkdir -p $INSTALL_PATH + +# Cache directory +RUN mkdir -p /.cache +RUN chown $UID:$GID /.cache + +# Install gems +WORKDIR $INSTALL_PATH +COPY source/ . +# RUN rm -rf node_modules +RUN rm -rf vendor +RUN gem install rails bundler +RUN bundle install +# RUN yarn install + +# Start server +CMD bundle exec unicorn -c config/unicorn.rb diff --git a/Dockerfile.nginx b/Dockerfile.nginx new file mode 100644 index 000000000..aa0c30a6c --- /dev/null +++ b/Dockerfile.nginx @@ -0,0 +1,5 @@ +FROM nginx:latest +COPY reverse-proxy.conf /etc/nginx/conf.d/reverse-proxy.conf +EXPOSE 3000 +STOPSIGNAL SIGTERM +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 7bbed0a5a..b76c7bf7b 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,199 @@ +# List Posts by Rating Challenge -# Challenges -Code challenges for developers and designers. +![Ruby](https://img.shields.io/badge/ruby-%23CC342D.svg?style=for-the-badge&logo=ruby&logoColor=white) +![Rails](https://img.shields.io/badge/rails-%23CC0000.svg?style=for-the-badge&logo=ruby-on-rails&logoColor=white) +![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) -We believe recruitment is the most critical part of an organization. People should experience some time working together before deciding on making it permanent. -This repo contains code challenges we'd like to see solved by people interested in working with us. +A Rails application that implements a Reddit-style post scoring system with logarithmic ranking algorithms and time decay factors. -We tend not to place job ads, as we prefer references and proactive candidates. With this in mind, feel free to take a challenge and let us know you're working on it. -Mostly, we work with Ruby and java, but also have some stuff in javascript. Even if you don't have experience in these technologies, as long as you are willing to learn them and want to build great stuff for the web, we can probably be a good fit. +## Technology Stack +- **Ruby**: 3.4.5 +- **Rails**: 8.0.2 +- **PostgreSQL**: Latest (Docker) +- **Docker**: Containerized development environment (Initial docker from https://github.com/muromeo1/rails-docker-boilerplate) -### Available challenges +## Challenge -#### Backend Development -* [User Changes](/1-development/user-changes.md) -* [Client from the Bahamas](/1-development/client-from-the-bahamas.md) -* [List Posts by Ratings](/1-development/list_posts_by_rating.md) +### List Posts by Rating -#### Frontend Development -* [React Challenge](/2-frontend/react-challenge.md) -* [Design Implementation Challenge](/3-design-frontend/design-frontend-challenge.md) +You are a web programmer. You have users. Your users rate posts on your site. You want to put the highest-rated posts at the top and lowest-rated at the bottom. You need some sort of "score" to sort by. +### Objectives -### How we work ## +We'd like to see a working web service with the following endpoints: -* **People** - you know, the guys in the team(s). We're not afraid to ask for help and we're not afraid to express our opinions. We're here to help.eachother. +``` +/upvote/:post_id +/downvote/:post_id +/posts/ +``` -* **Culture** - We follow RUPEAL's guiding principles: +No UI is required. You can use a database of your choice. - * Give your best - * Show That You Care - * Build an environment of strong, open and honest relationships - * Deliver WOW through your service - * Stay humble - * Do What's Right - * Be coachable and don’t take it personally - * Do more with less - * Pursue growth and personal development - * Have fun +Tech stack: Feel free to use the stack that you feel more comfortable.This means you can choose any language you need to get the job done. -* **You** - by joining our team, feel free to question all the items below and propose new ideas on how we work. This is definitely not a static thing. +Your solution should consider that if you have a first post with 600 up votes and 400 down votes means that you have 60% of up votes and 40% of down votes, and if you have another post with 6 up votes and 4 down votes you have the same % of the previous post, 60% of up votes and 40% of down votes. -* **GitHub** - all our code is hosted here. We use Pull Requests and do code reviews for those. Everyone on the team reviews PRs. It's expected that you write quality code with automated tests. Once a PR is reviewed and accepted, the person who opened the PR should merge it and delete the branch. +Hint: Note that the score in % of the two posts are equal, but the real "values" are significantly differents -* **Continuous Integration** - we use Semaphore and CircleCI for our CI needs. Everytime we push to a branch, our test suite runs on Semaphore on some projects, CircleCI on others. +You should be doing the solution on a specific branch. Once you have something to delivery, you can open a Pull Request. You can use the Pull Request if you'd like some feedback on your code or to discuss something with us. -* **Slack** - we mostly communicate asynchronously between the development team and with other teams. This is our chat tool. Abuse it. +### Things We value -* **Jira** - our products' backlog is managed in Jira. Once we estimate all known user-stories, the sprint backlog is automatically built. Each team member will select a user-story to work from that sprint backlog and will update the needed tasks' status. This way we keep focused until the end of the sprint. +- Clean code, we want you to show us the best code you can do +- If you are familiar with TDD, BDD or any testing process, please show us your skills -* **AWS** - our products are mainly hosted on AWS, so a basic knowledge of it is appreciated. +Tech stack: please use preferably Ruby / Rails or JAVA, but if you are more versatile in a different tech stack, you may use one of your choice. -* **Support tasks** - We are paranoid about providing top notch support to our customers. We have a dedicated customer support team working full-time communicating with clients. Our development team works very closely with the customer support team delivering happiness to our clients. + +## Features + +- **Post Scoring System**: Implements a logarithmic scoring algorithm similar to Reddit's ranking system +- **Vote Management**: Supports upvotes and downvotes with proper score calculations + +## Scoring Algorithm +I decided to use Logarithmic Score (Reddit-style) because: +- Balances popularity and recency (old content naturally decays in ranking even if it has many votes) +- New content with even moderate upvotes can rise quickly +- Simple and fast to compute +- No confidence intervals or priors (just basic math and log) +- Directional control with sign +- Separates positive vs. negative reactions (posts with more downvotes than upvotes sink faster) +- Encourages early engagement (the time boost helps early upvoting significantly, rewarding viral momentum) + +## Getting Started + +### Prerequisites + +- Docker +- Docker Compose + +### Installation + +1. Clone the repository: +```bash +git clone +cd list_posts_by_rating_challenge +``` + +2. Start the application: +```bash +docker compose build +docker compose up +``` + +4. Access the application at `http://localhost:3000` + +## Usage + +### Running Commands + +Use the `bin/run` script to execute commands within the Docker container: + +```bash +# Rails commands +bin/run rails console +bin/run rails routes +bin/run rails db:migrate +bin/run rails db:seed + +# Ruby commands +bin/run ruby -v +bin/run bundle install +``` + +### API Endpoints + +- `GET /posts` - List all posts with scores +- `POST /posts/:id/upvote` - Upvote a post +- `POST /posts/:id/downvote` - Downvote a post + +### Interface +Access the application at `http://localhost:3000` and interact with the visual interface, it will show you the upvote and downvote endpoints working plus the score system working. + +## Development + +### Project Structure + +``` +source/ +├── app/ +│ ├── controllers/ +│ │ ├── application_controller.rb +│ │ ├── posts_controller.rb # Web interface controller +│ │ └── api/ +│ │ └── v1/ +│ │ └── posts_controller.rb # API endpoints for posts +│ ├── models/ +│ │ ├── application_record.rb +│ │ └── post.rb # Post model with scoring logic +│ ├── services/ +│ │ └── post_scoring_service.rb # Core scoring algorithm +│ ├── views/ +│ │ ├── layouts/ +│ │ │ └── application.html.erb +│ │ └── posts/ +│ │ ├── index.html.erb # Main posts listing page +│ │ └── _table.html.erb # Posts table partial +│ └── assets/ +│ ├── stylesheets/ +│ └── javascript/ +├── config/ +├── db/ +│ ├── migrate/ +│ │ └── 20250811120000_create_posts.rb # Posts table migration + +│ └── seeds.rb # Sample data +├── spec/ +│ ├── models/ +│ │ └── post_spec.rb # Post model tests +│ ├── controllers/ +│ │ ├── posts_controller_spec.rb # Web controller tests +│ │ └── api/ +│ │ └── v1/ +│ │ └── posts_controller_spec.rb # API controller tests +│ ├── services/ +│ │ └── post_scoring_service_spec.rb # Scoring service tests +│ ├── requests/ +│ │ └── api/ +│ │ └── v1/ +│ │ └── posts_spec.rb # API request tests +│ ├── factories/ +│ │ └── posts.rb # Test data factories +│ └── features/ +│ └── posts_spec.rb # Integration tests + +``` + +### Key Files + +- `app/services/post_scoring_service.rb` - Main scoring algorithm implementation +- `app/models/post.rb` - Post model with vote tracking +- `app/controllers/api/v1/posts_controller.rb` - API endpoints for posts + +### Testing + +Run the test suite: + +```bash +bin/run rspec +``` + +## Configuration + +### Time Decay Factor + +The time decay factor is configurable in `PostScoringService`: + +```ruby +TIME_DECAY_FACTOR = 45_000 # 12.5 hours in seconds, used by reddit +``` + +### Database + +The application uses PostgreSQL for data persistence and efficient scoring queries. + +## Notes +- The scoring system was translated to ruby with the help of AI +- The tests where created with the help of AI \ No newline at end of file diff --git a/bin/new_app b/bin/new_app new file mode 100644 index 000000000..b204f65ab --- /dev/null +++ b/bin/new_app @@ -0,0 +1,12 @@ +#!/bin/bash + +read -p 'Enter new project name: ' name +cp -a ../rails-docker-boilerplate ../$name +cd ../$name +rm -rf .bin/new_app +grep -lR "????" ./env-example | xargs sed -i "s/????/$name/g" +sudo rm -rf ../rails-docker-boilerplate +rm -rf .git +git init -q +git checkout -qb main +$SHELL diff --git a/bin/run b/bin/run new file mode 100644 index 000000000..acee8e69f --- /dev/null +++ b/bin/run @@ -0,0 +1,3 @@ +#!/bin/bash + +docker compose run --rm source $@ diff --git a/bin/setup b/bin/setup new file mode 100644 index 000000000..8a6523005 --- /dev/null +++ b/bin/setup @@ -0,0 +1,25 @@ +#!/bin/bash + +echo == Copying env files == +cp env-example .env +echo "UID=$(id -u)" >> .env +echo "GID=$(id -g)" >> .env +echo + +echo == Creating volumes == +docker volume create --name source-postgres +echo + +echo == Building docker compose == +docker compose build +echo + +echo == Initializing database == +docker compose run --rm source rake db:reset +docker compose run --rm source rake db:migrate +docker compose run --rm source rake db:test:prepare +echo + +echo == Generating secret == +echo "SECRET_TOKEN=$(docker compose run --rm source rake secret)" >> .env +echo == END == diff --git a/bin/uninstall b/bin/uninstall new file mode 100644 index 000000000..b6a1454bd --- /dev/null +++ b/bin/uninstall @@ -0,0 +1,8 @@ +#!/bin/bash + +echo == Stopping containers == +# docker stop $(docker ps -aqf "name=rails-docker-boilerplate") +docker stop $(docker ps -q) + +echo == Delete containers == +docker system prune -a -f --volumes diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..47325ad5b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + postgres: + image: postgres:latest + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - '5432:5432' + volumes: + - source-postgres:/var/lib/postgresql/data + + source: + build: + context: . + args: + UID: "${UID}" + GID: "${GID}" + volumes: + - ./source:/opt/app + links: + - postgres + ports: + - '8010:8010' + env_file: + - .env + user: "${UID}:${GID}" + + nginx: + build: + context: . + dockerfile: ./Dockerfile.nginx + links: + - source + ports: + - '3000:3000' + +volumes: + source-postgres: diff --git a/env-example b/env-example new file mode 100644 index 000000000..50a7f8ebf --- /dev/null +++ b/env-example @@ -0,0 +1,6 @@ +WORKER_PROCESSES=1 +LISTEN_ON=0.0.0.0:8010 +DATABASE_URL=postgresql://postgres:postgres@postgres:5432/list_posts_by_rating_challenge?encoding=utf8&pool=5&timeout=5000 +UID=1000 +GID=1000 +SECRET_TOKEN= diff --git a/reverse-proxy.conf b/reverse-proxy.conf new file mode 100644 index 000000000..fad1eabce --- /dev/null +++ b/reverse-proxy.conf @@ -0,0 +1,10 @@ +server { + listen 3000; + server_name example.org; + + location / { + proxy_pass http://source:8010; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/source/.gitattributes b/source/.gitattributes new file mode 100644 index 000000000..31eeee0b6 --- /dev/null +++ b/source/.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/source/.gitignore b/source/.gitignore new file mode 100644 index 000000000..e16dc71d2 --- /dev/null +++ b/source/.gitignore @@ -0,0 +1,31 @@ +# 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' + +# Ignore bundler config. +/.bundle + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore uploaded files in development. +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key diff --git a/source/.irbrc b/source/.irbrc new file mode 100644 index 000000000..2507b8ca1 --- /dev/null +++ b/source/.irbrc @@ -0,0 +1 @@ +IRB.conf[:USE_AUTOCOMPLETE] = false diff --git a/source/.reek.yml b/source/.reek.yml new file mode 100644 index 000000000..4cb3057dd --- /dev/null +++ b/source/.reek.yml @@ -0,0 +1,9 @@ +detectors: + IrresponsibleModule: + enabled: false + + UtilityFunction: + public_methods_only: true + +exclude_paths: + - db diff --git a/source/.rubocop.yml b/source/.rubocop.yml new file mode 100644 index 000000000..64e60b636 --- /dev/null +++ b/source/.rubocop.yml @@ -0,0 +1,27 @@ +require: + rubocop + +AllCops: + NewCops: enable + TargetRubyVersion: 3.4 + Exclude: + - 'bin/**/*' + - 'db/**/*' + - 'vendor/**/*' + - 'script/**/*' + - 'spec/rails_helper.rb' + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Metrics/BlockLength: + IgnoredMethods: ['describe', 'context', 'feature', 'scenario', 'let'] + +Layout/LineLength: + Max: 140 + +Naming/RescuedExceptionsVariableName: + Enabled: false diff --git a/source/.ruby-version b/source/.ruby-version new file mode 100644 index 000000000..54978911c --- /dev/null +++ b/source/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.5 diff --git a/source/Gemfile b/source/Gemfile new file mode 100644 index 000000000..19f5521e5 --- /dev/null +++ b/source/Gemfile @@ -0,0 +1,29 @@ +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +gem 'bootsnap', require: false +gem 'importmap-rails' +gem 'jbuilder' +gem 'pg' +gem 'rails' +gem 'sprockets-rails' +gem 'stimulus-rails' +gem 'turbo-rails' +gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem 'unicorn' + +group :development, :test do + gem 'debug', platforms: %i[mri mingw x64_mingw] + gem 'factory_bot' + gem 'faker' + gem 'rspec-rails' + gem 'capybara' + gem 'selenium-webdriver' + gem 'rails-controller-testing' +end + +group :development do + gem 'reek' + gem 'rubocop' + gem 'web-console' +end diff --git a/source/Gemfile.lock b/source/Gemfile.lock new file mode 100644 index 000000000..f2cf503cf --- /dev/null +++ b/source/Gemfile.lock @@ -0,0 +1,361 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.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.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.2) + activesupport (= 8.0.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.2) + activesupport (= 8.0.2) + globalid (>= 0.3.6) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) + timeout (>= 0.4.0) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) + marcel (~> 1.0) + activesupport (8.0.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.2) + bindex (0.8.1) + bootsnap (1.18.6) + msgpack (~> 1.2) + builder (3.3.0) + capybara (3.40.0) + addressable + 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) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + crass (1.0.6) + date (3.4.1) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.2) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.14.1) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.5) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.8.3) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + erb (5.0.2) + erubi (1.13.1) + factory_bot (6.5.4) + activesupport (>= 6.1.0) + faker (3.5.2) + i18n (>= 1.8.11, < 2) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.0) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.13.2) + kgio (2.11.4) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.3) + mini_mime (1.1.5) + minitest (5.25.5) + msgpack (1.8.0) + net-imap (0.5.9) + 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.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pg (1.6.1-x86_64-linux) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + racc (1.8.1) + rack (3.2.0) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + bundler (>= 1.15.0) + railties (= 8.0.2) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) + 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.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + raindrops (0.20.1) + rake (13.3.0) + rdoc (6.14.2) + erb + psych (>= 4.0.0) + reek (6.5.0) + dry-schema (~> 1.13) + logger (~> 1.6) + parser (~> 3.3.0) + rainbow (>= 2.0, < 4.0) + rexml (~> 3.1) + regexp_parser (2.11.1) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.1) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.1) + 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.4) + rubocop (1.79.2) + 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.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + rubyzip (2.4.1) + securerandom (0.4.1) + selenium-webdriver (4.34.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.7) + thor (1.4.0) + timeout (0.4.3) + turbo-rails (2.0.16) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + unicorn (6.1.0) + kgio (~> 2.6) + raindrops (~> 0.7) + uri (1.0.3) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + bootsnap + capybara + debug + factory_bot + faker + importmap-rails + jbuilder + pg + rails + rails-controller-testing + reek + rspec-rails + rubocop + selenium-webdriver + sprockets-rails + stimulus-rails + turbo-rails + tzinfo-data + unicorn + web-console + +BUNDLED WITH + 2.7.1 diff --git a/source/Rakefile b/source/Rakefile new file mode 100644 index 000000000..e85f91391 --- /dev/null +++ b/source/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/source/app/assets/config/manifest.js b/source/app/assets/config/manifest.js new file mode 100644 index 000000000..ddd546a0b --- /dev/null +++ b/source/app/assets/config/manifest.js @@ -0,0 +1,4 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js +//= link_tree ../../../vendor/javascript .js diff --git a/source/app/assets/images/.keep b/source/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/assets/stylesheets/application.css b/source/app/assets/stylesheets/application.css new file mode 100644 index 000000000..bf5b4f33e --- /dev/null +++ b/source/app/assets/stylesheets/application.css @@ -0,0 +1,163 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ + +/* Error message styling */ +.error-message { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 0.375rem; + color: #721c24; + padding: 0.75rem 1rem; + margin: 1rem 0; + font-size: 0.875rem; + display: none; + animation: fadeIn 0.3s ease-in; +} + +.error-message.show { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Loading state styling */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid #f3f3f3; + border-top: 2px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Flash message styling */ +.flash-messages { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + max-width: 400px; +} + +.flash-message { + padding: 1rem; + margin-bottom: 0.5rem; + border-radius: 0.375rem; + border: 1px solid transparent; + position: relative; + animation: slideIn 0.3s ease-out; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.flash-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.flash-close:hover { + opacity: 1; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.flash-message.error { + background-color: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} + +.flash-message.success { + background-color: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +.flash-message.warning { + background-color: #fff3cd; + border-color: #ffeaa7; + color: #856404; +} + +.flash-message.info { + background-color: #d1ecf1; + border-color: #bee5eb; + color: #0c5460; +} + +/* Button states */ +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn.loading { + position: relative; + color: transparent; +} + +.btn.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} diff --git a/source/app/assets/stylesheets/posts.css b/source/app/assets/stylesheets/posts.css new file mode 100644 index 000000000..5dc979ed0 --- /dev/null +++ b/source/app/assets/stylesheets/posts.css @@ -0,0 +1,37 @@ +.btn { + padding: 5px 10px; + margin: 2px; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-success { + background-color: #28a745; + color: white; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +th { + background-color: #f2f2f2; +} diff --git a/source/app/channels/application_cable/channel.rb b/source/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..d67269728 --- /dev/null +++ b/source/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/source/app/channels/application_cable/connection.rb b/source/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..0ff5442f4 --- /dev/null +++ b/source/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/source/app/controllers/api/v1/posts_controller.rb b/source/app/controllers/api/v1/posts_controller.rb new file mode 100644 index 000000000..65cc6583f --- /dev/null +++ b/source/app/controllers/api/v1/posts_controller.rb @@ -0,0 +1,73 @@ +module Api + module V1 + class PostsController < ApplicationController + # Used this to so the controller won't require CSRF tokens for requests (specific to this case because I don't want to use any kind of authentication) + protect_from_forgery with: :null_session + + before_action :set_post, only: %i[upvote downvote] + + def index + posts = Post.ordered_by_score + render json: posts.map { |p| serialize_post(p) } + rescue StandardError => e + log_error(e) + render json: { error: 'Failed to fetch posts' }, status: :internal_server_error + end + + def upvote + @post.with_lock do + @post.increment!(:upvotes) + end + render json: serialize_post(@post) + rescue ActiveRecord::StaleObjectError => e + log_error(e) + render json: { error: 'Post was updated by another user. Please try again.' }, status: :conflict + rescue StandardError => e + log_error(e) + render json: { error: 'Failed to upvote post' }, status: :internal_server_error + end + + def downvote + @post.with_lock do + @post.increment!(:downvotes) + end + render json: serialize_post(@post) + rescue ActiveRecord::StaleObjectError => e + log_error(e) + render json: { error: 'Post was updated by another user. Please try again.' }, status: :conflict + rescue StandardError => e + log_error(e) + render json: { error: 'Failed to downvote post' }, status: :internal_server_error + end + + private + + def set_post + @post = Post.find(params[:id]) + rescue ActiveRecord::RecordNotFound => e + log_error(e) + render json: { error: 'Post not found' }, status: :not_found + end + + def serialize_post(post) + { + id: post.id, + title: post.title, + upvotes: post.upvotes, + downvotes: post.downvotes, + total_votes: post.total_votes, + score: post.score + } + end + + # Added this to log errors to the console to simplify the process of debugging + # If was a bigger app, I would use a more robust logging system like Sentry or other. + def log_error(exception) + Rails.logger.error "API Error: #{exception.class} - #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") if Rails.env.development? + end + end + end +end + + diff --git a/source/app/controllers/application_controller.rb b/source/app/controllers/application_controller.rb new file mode 100644 index 000000000..0e9c92609 --- /dev/null +++ b/source/app/controllers/application_controller.rb @@ -0,0 +1,3 @@ +class ApplicationController < ActionController::Base + include ErrorHandler +end diff --git a/source/app/controllers/concerns/.keep b/source/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/controllers/concerns/error_handler.rb b/source/app/controllers/concerns/error_handler.rb new file mode 100644 index 000000000..3de71fbba --- /dev/null +++ b/source/app/controllers/concerns/error_handler.rb @@ -0,0 +1,71 @@ +module ErrorHandler + extend ActiveSupport::Concern + + included do + rescue_from StandardError, with: :handle_standard_error + rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found + rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error + rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing + rescue_from ActionController::UnpermittedParameters, with: :handle_unpermitted_parameters + end + + private + + def handle_standard_error(exception) + log_error(exception) + + respond_to do |format| + format.html { render 'errors/internal_server_error', status: :internal_server_error } + format.json { render json: { error: 'Internal server error' }, status: :internal_server_error } + end + end + + def handle_not_found(exception) + log_error(exception) + + respond_to do |format| + format.html { render 'errors/not_found', status: :not_found } + format.json { render json: { error: 'Resource not found' }, status: :not_found } + end + end + + def handle_validation_error(exception) + log_error(exception) + + respond_to do |format| + format.html { + flash[:error] = "Validation failed: #{exception.record.errors.full_messages.join(', ')}" + redirect_back(fallback_location: root_path) + } + format.json { + render json: { + error: 'Validation failed', + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + } + end + end + + def handle_parameter_missing(exception) + log_error(exception) + + respond_to do |format| + format.html { render 'errors/bad_request', status: :bad_request } + format.json { render json: { error: "Missing parameter: #{exception.param}" }, status: :bad_request } + end + end + + def handle_unpermitted_parameters(exception) + log_error(exception) + + respond_to do |format| + format.html { render 'errors/bad_request', status: :bad_request } + format.json { render json: { error: "Unpermitted parameters: #{exception.params}" }, status: :bad_request } + end + end + + def log_error(exception) + Rails.logger.error "Error: #{exception.class} - #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") if Rails.env.development? + end +end diff --git a/source/app/controllers/posts_controller.rb b/source/app/controllers/posts_controller.rb new file mode 100644 index 000000000..db641cc92 --- /dev/null +++ b/source/app/controllers/posts_controller.rb @@ -0,0 +1,18 @@ +class PostsController < ApplicationController + def index + @posts = Post.ordered_by_score + rescue StandardError => e + log_error(e) + flash[:error] = "Failed to load posts. Please try again later." + @posts = [] + end + + private + + def log_error(exception) + Rails.logger.error "Posts Controller Error: #{exception.class} - #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") if Rails.env.development? + end +end + + diff --git a/source/app/helpers/application_helper.rb b/source/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/source/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/source/app/javascript/application.js b/source/app/javascript/application.js new file mode 100644 index 000000000..de5b27a99 --- /dev/null +++ b/source/app/javascript/application.js @@ -0,0 +1,4 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails + +import "@hotwired/turbo-rails" +import "controllers" diff --git a/source/app/javascript/controllers/application.js b/source/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/source/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/source/app/javascript/controllers/index.js b/source/app/javascript/controllers/index.js new file mode 100644 index 000000000..b4ea10e9d --- /dev/null +++ b/source/app/javascript/controllers/index.js @@ -0,0 +1,11 @@ +// Import and register all your controllers from the importmap under controllers/**/*_controller + +import { application } from "controllers/application" + +// Eager load all controllers defined in the import map under controllers/**/*_controller +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) + +// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) +// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" +// lazyLoadControllersFrom("controllers", application) diff --git a/source/app/javascript/controllers/posts_controller.js b/source/app/javascript/controllers/posts_controller.js new file mode 100644 index 000000000..3cf741fc2 --- /dev/null +++ b/source/app/javascript/controllers/posts_controller.js @@ -0,0 +1,78 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + upvote(event) { + event.preventDefault() + const postId = event.currentTarget.dataset.postId + this.vote(postId, 'upvote') + } + + downvote(event) { + event.preventDefault() + const postId = event.currentTarget.dataset.postId + this.vote(postId, 'downvote') + } + + vote(postId, action) { + const button = event.currentTarget + const originalText = button.textContent + + // Disable button and show loading state + button.disabled = true + button.textContent = '...' + + fetch(`/api/v1/posts/${postId}/${action}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' + } + }) + .then(response => { + if (response.ok) { + return response.json().then(data => { + window.location.reload() + }) + } else { + // Parse the error response to get the actual error message + return response.json().then(errorData => { + const errorMessage = errorData.error || 'An error occurred while processing your request.' + throw new Error(errorMessage) + }).catch(() => { + // If JSON parsing fails, use a generic message based on status code + const statusMessages = { + 404: 'Post not found. It may have been deleted.', + 409: 'Post was updated by another user. Please try again.', + 422: 'Invalid request. Please check your input.', + 500: 'Server error. Please try again later.' + } + const message = statusMessages[response.status] || `Server error (${response.status}). Please try again.` + throw new Error(message) + }) + } + }) + .catch(error => { + this.showErrorMessage(error.message) + button.textContent = originalText + button.disabled = false + }) + } + + showErrorMessage(message) { + // Create or find error message element + let errorElement = document.querySelector('.error-message') + if (!errorElement) { + errorElement = document.createElement('div') + errorElement.className = 'error-message' + this.element.appendChild(errorElement) + } + + errorElement.textContent = message + errorElement.style.display = 'block' + + // Auto-hide error message after 5 seconds + setTimeout(() => { + errorElement.style.display = 'none' + }, 5000) + } +} diff --git a/source/app/jobs/application_job.rb b/source/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/source/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/source/app/mailers/application_mailer.rb b/source/app/mailers/application_mailer.rb new file mode 100644 index 000000000..286b2239d --- /dev/null +++ b/source/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end diff --git a/source/app/models/application_record.rb b/source/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/source/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/source/app/models/concerns/.keep b/source/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/models/post.rb b/source/app/models/post.rb new file mode 100644 index 000000000..f17436ddf --- /dev/null +++ b/source/app/models/post.rb @@ -0,0 +1,37 @@ +class Post < ApplicationRecord + # Validations with custom error messages + validates :title, presence: { message: "Title cannot be blank" } + validates :upvotes, presence: { message: "Upvotes count is required" }, + numericality: { + greater_than_or_equal_to: 0, + message: "Upvotes must be a non-negative number" + } + validates :downvotes, presence: { message: "Downvotes count is required" }, + numericality: { + greater_than_or_equal_to: 0, + message: "Downvotes must be a non-negative number" + } + + # Orders posts by Reddit-style logarithmic score (descending) + scope :ordered_by_score, lambda { + # Use a simpler approach that can be reversed + order(Arel.sql("#{PostScoringService.logarithmic_score_sql} DESC")) + } + + # Alternative method for ordering that can be reversed + def self.ordered_by_score_reversible + all.sort_by { |post| -post.score } + end + + # Delegate scoring methods to the service + def score + PostScoringService.calculate_score(self) + end + + def total_votes + PostScoringService.new.total_votes(self) + end + +end + + diff --git a/source/app/services/post_scoring_service.rb b/source/app/services/post_scoring_service.rb new file mode 100644 index 000000000..31b2f3aa8 --- /dev/null +++ b/source/app/services/post_scoring_service.rb @@ -0,0 +1,55 @@ +class PostScoringService + #This service is used to calculate the score of a post. It reflects the Reddit-style logarithmic score. + + class << self + # SQL version for database ordering + def logarithmic_score_sql + <<~SQL.squish + CASE + WHEN (upvotes + downvotes) = 0 THEN 0 + ELSE LOG10(GREATEST(ABS(upvotes - downvotes), 1)) + + SIGN(upvotes - downvotes) * (EXTRACT(EPOCH FROM created_at) - 1134028003) / 45000.0 + END + SQL + end + + # Calculate score for a single post + def calculate_score(post) + new.calculate_score(post) + end + + # Calculate scores for multiple posts + def calculate_scores(posts) + posts.map { |post| [post, calculate_score(post)] } + end + + # Sort posts by score + def sort_by_score(posts) + posts.sort_by { |post| -calculate_score(post) } + end + end + + # Instance method for calculating score + def calculate_score(post) + vote_diff = post.upvotes - post.downvotes + sign = vote_diff <=> 0 # -1, 0, or 1 + + # Log component (always positive or zero) + log_vote_diff = Math.log10([vote_diff.abs, 1].max) + + # Reddit epoch: Dec 8, 2005 + reddit_epoch = Time.at(1134028003) + time_since_reddit_epoch = (post.created_at || Time.current) - reddit_epoch + + # Time component, affected by vote direction + time_score = sign * (time_since_reddit_epoch.to_f / 45000.0) + + # Final Reddit-style score + log_vote_diff + time_score + end + + # Calculate total votes for a post + def total_votes(post) + post.upvotes + post.downvotes + end +end diff --git a/source/app/views/errors/bad_request.html.erb b/source/app/views/errors/bad_request.html.erb new file mode 100644 index 000000000..ccd1c778b --- /dev/null +++ b/source/app/views/errors/bad_request.html.erb @@ -0,0 +1,69 @@ +
+
+

400 - Bad Request

+

The request could not be processed. Please check your input and try again.

+
+ <%= link_to "Go Home", root_path, class: "btn btn-primary" %> + <%= link_to "Go Back", :back, class: "btn btn-secondary" %> +
+
+
+ + diff --git a/source/app/views/errors/internal_server_error.html.erb b/source/app/views/errors/internal_server_error.html.erb new file mode 100644 index 000000000..6589d5b9d --- /dev/null +++ b/source/app/views/errors/internal_server_error.html.erb @@ -0,0 +1,69 @@ +
+
+

500 - Internal Server Error

+

Something went wrong on our end. We're working to fix this issue.

+
+ <%= link_to "Go Home", root_path, class: "btn btn-primary" %> + <%= link_to "Try Again", request.referer || root_path, class: "btn btn-secondary" %> +
+
+
+ + diff --git a/source/app/views/errors/not_found.html.erb b/source/app/views/errors/not_found.html.erb new file mode 100644 index 000000000..8fbe8cdf3 --- /dev/null +++ b/source/app/views/errors/not_found.html.erb @@ -0,0 +1,69 @@ +
+
+

404 - Page Not Found

+

Sorry, the page you're looking for doesn't exist.

+
+ <%= link_to "Go Home", root_path, class: "btn btn-primary" %> + <%= link_to "Go Back", :back, class: "btn btn-secondary" %> +
+
+
+ + diff --git a/source/app/views/layouts/application.html.erb b/source/app/views/layouts/application.html.erb new file mode 100644 index 000000000..13b645f5e --- /dev/null +++ b/source/app/views/layouts/application.html.erb @@ -0,0 +1,27 @@ + + + + Source + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <% if flash.any? %> +
+ <% flash.each do |type, message| %> +
+ <%= message %> + +
+ <% end %> +
+ <% end %> + + <%= yield %> + + diff --git a/source/app/views/posts/_table.html.erb b/source/app/views/posts/_table.html.erb new file mode 100644 index 000000000..13d06499c --- /dev/null +++ b/source/app/views/posts/_table.html.erb @@ -0,0 +1,33 @@ +
+ + + + + + + + + + + + + <% posts.each do |post| %> + + + + + + + + + <% end %> + +
TitleUpvotesDownvotesTotalScoreActions
<%= post.title %><%= post.upvotes %><%= post.downvotes %><%= post.total_votes %><%= format('%.6f', post.score) %> + + +
+
diff --git a/source/app/views/posts/index.html.erb b/source/app/views/posts/index.html.erb new file mode 100644 index 000000000..9080b1a17 --- /dev/null +++ b/source/app/views/posts/index.html.erb @@ -0,0 +1,5 @@ +

Posts

+ +<%= render 'table', posts: @posts %> + + diff --git a/source/bin/importmap b/source/bin/importmap new file mode 100644 index 000000000..36502ab16 --- /dev/null +++ b/source/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/source/bin/rails b/source/bin/rails new file mode 100644 index 000000000..efc037749 --- /dev/null +++ b/source/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/source/bin/rake b/source/bin/rake new file mode 100644 index 000000000..4fbf10b96 --- /dev/null +++ b/source/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/source/config.ru b/source/config.ru new file mode 100644 index 000000000..ad1fbf295 --- /dev/null +++ b/source/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative 'config/environment' + +run Rails.application +Rails.application.load_server diff --git a/source/config/application.rb b/source/config/application.rb new file mode 100644 index 000000000..737ec809e --- /dev/null +++ b/source/config/application.rb @@ -0,0 +1,29 @@ +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 Source + class Application < Rails::Application + config.load_defaults 7.0 + config.log_level = :debug + config.log_tags = %i[subdomain uuid] + config.logger = ActiveSupport::TaggedLogging.new(Logger.new($stdout)) + config.generators.system_tests = nil + end +end diff --git a/source/config/boot.rb b/source/config/boot.rb new file mode 100644 index 000000000..b9e460cef --- /dev/null +++ b/source/config/boot.rb @@ -0,0 +1,4 @@ +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/source/config/cable.yml b/source/config/cable.yml new file mode 100644 index 000000000..b63751628 --- /dev/null +++ b/source/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: source_production diff --git a/source/config/credentials.yml.enc b/source/config/credentials.yml.enc new file mode 100644 index 000000000..2648e944d --- /dev/null +++ b/source/config/credentials.yml.enc @@ -0,0 +1 @@ +jJ31ZX2hv9bSXX1hSlT5yoprHH7YRF37sr97nJdPn5n08WN4BBLldGcqjCS2aNbb95jGNFl0dXw71ggFDohBaoR68G5iPfcaNjSir6AP5zHJ09Tw46tPZfCe0YGzUOTIQn1viBuWDyUQdzxBomdemEyYZ0bGbNEY47LK9Lpr/kWsr7PoCkGEMp1sCudYiG9T8T6Gmx1moCih652EPjfsDHzlTLbJuMug60AJ+PJYNgizQuLfBas8qiQ2GaTvAH0SOjGazpMk9MqK+Ox7yZGSw/B3I+ss0bxDLbAWty36iIW5et3lta4iOZAq7qYW8b5WMDeK2R7e2Ej3XofVmaXu4LQiHi5ReIwNMBqQ/TFXl2pw0LudWyAvTiQf7kHP5FLs7GKedLJfwTkyjR8Y4oEnwgW9azOA7iP3nEbY--jYDNArTx9HJ8M4pS--kb2X1vj2FYz//iKwMR0Vow== \ No newline at end of file diff --git a/source/config/database.yml b/source/config/database.yml new file mode 100644 index 000000000..9ec165ffd --- /dev/null +++ b/source/config/database.yml @@ -0,0 +1,8 @@ +development: + url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + +test: + url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + +production: + url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> diff --git a/source/config/environment.rb b/source/config/environment.rb new file mode 100644 index 000000000..426333bb4 --- /dev/null +++ b/source/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/source/config/environments/development.rb b/source/config/environments/development.rb new file mode 100644 index 000000000..31a0fb2e6 --- /dev/null +++ b/source/config/environments/development.rb @@ -0,0 +1,72 @@ +require 'active_support/core_ext/integer/time' + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # 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 caching. By default caching is disabled. + # Run rails dev:cache to toggle 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.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # 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 + + # Suppress logger output for asset requests. + config.assets.quiet = 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 + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + config.hosts << 'boilerplate' +end diff --git a/source/config/environments/production.rb b/source/config/environments/production.rb new file mode 100644 index 000000000..6e0fd86f6 --- /dev/null +++ b/source/config/environments/production.rb @@ -0,0 +1,93 @@ +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.cache_classes = true + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Include generic and useful information about system operation, but avoid logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). + config.log_level = :info + + # Prepend all log lines with the following tags. + config.log_tags = [:request_id] + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "source_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # 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 + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Use default logging formatter so that PID and timestamp are not suppressed. + config.log_formatter = ::Logger::Formatter.new + + # Use a different logger for distributed setups. + # require "syslog/logger" + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") + + if ENV['RAILS_LOG_TO_STDOUT'].present? + logger = ActiveSupport::Logger.new($stdout) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/source/config/environments/test.rb b/source/config/environments/test.rb new file mode 100644 index 000000000..5f6cef4d6 --- /dev/null +++ b/source/config/environments/test.rb @@ -0,0 +1,60 @@ +require 'active_support/core_ext/integer/time' + +# 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. + + # Turn false under Spring and add config.action_view.cache_template_loading = true. + config.cache_classes = true + + # Eager loading loads your whole application. When running a single test locally, + # this probably isn't necessary. It's a good idea to do in a continuous integration + # system, or in some way 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.enabled = true + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # 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 +end diff --git a/source/config/importmap.rb b/source/config/importmap.rb new file mode 100644 index 000000000..8e77ab6f0 --- /dev/null +++ b/source/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true +pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/source/config/initializers/assets.rb b/source/config/initializers/assets.rb new file mode 100644 index 000000000..fe48fc34e --- /dev/null +++ b/source/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# 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 + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/source/config/initializers/content_security_policy.rb b/source/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..54f47cf15 --- /dev/null +++ b/source/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# 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 and inline scripts +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/source/config/initializers/filter_parameter_logging.rb b/source/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..166997c5a --- /dev/null +++ b/source/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be 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 += %i[ + passw secret token _key crypt salt certificate otp ssn +] diff --git a/source/config/initializers/inflections.rb b/source/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/source/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# 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) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/source/config/initializers/permissions_policy.rb b/source/config/initializers/permissions_policy.rb new file mode 100644 index 000000000..00f64d71b --- /dev/null +++ b/source/config/initializers/permissions_policy.rb @@ -0,0 +1,11 @@ +# 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 |f| +# f.camera :none +# f.gyroscope :none +# f.microphone :none +# f.usb :none +# f.fullscreen :self +# f.payment :self, "https://secure.example.com" +# end diff --git a/source/config/locales/en.yml b/source/config/locales/en.yml new file mode 100644 index 000000000..8ca56fc74 --- /dev/null +++ b/source/config/locales/en.yml @@ -0,0 +1,33 @@ +# Files in the config/locales directory are used for internationalization +# and are automatically loaded by Rails. If you want to use locales other +# than English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# The following keys must be escaped otherwise they will not be retrieved by +# the default I18n backend: +# +# true, false, on, off, yes, no +# +# Instead, surround them with single quotes. +# +# en: +# "true": "foo" +# +# To learn more, please read the Rails Internationalization guide +# available at https://guides.rubyonrails.org/i18n.html. + +en: + hello: "Hello world" diff --git a/source/config/puma.rb b/source/config/puma.rb new file mode 100644 index 000000000..059371274 --- /dev/null +++ b/source/config/puma.rb @@ -0,0 +1,43 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) +min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch('PORT', 3000) + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch('RAILS_ENV') { 'development' } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch('PIDFILE') { 'tmp/pids/server.pid' } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +# workers ENV.fetch("WEB_CONCURRENCY") { 2 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +# preload_app! + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/source/config/routes.rb b/source/config/routes.rb new file mode 100644 index 000000000..f4c107071 --- /dev/null +++ b/source/config/routes.rb @@ -0,0 +1,15 @@ +Rails.application.routes.draw do + root 'posts#index' + resources :posts, only: [:index] + + namespace :api do + namespace :v1 do + resources :posts, only: [:index] do + member do + post :upvote + post :downvote + end + end + end + end +end diff --git a/source/config/secrets.yml b/source/config/secrets.yml new file mode 100644 index 000000000..075d64f68 --- /dev/null +++ b/source/config/secrets.yml @@ -0,0 +1,8 @@ +development: &default + secret_key_base: <%= ENV['SECRET_TOKEN'] %> + +test: + <<: *default + +production: + <<: *default diff --git a/source/config/storage.yml b/source/config/storage.yml new file mode 100644 index 000000000..4942ab669 --- /dev/null +++ b/source/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/source/config/unicorn.rb b/source/config/unicorn.rb new file mode 100644 index 000000000..fde3842fa --- /dev/null +++ b/source/config/unicorn.rb @@ -0,0 +1,27 @@ +# https://github.com/gitlabhq/gitlabhq/blob/master/config/unicorn.rb.example + +worker_processes ENV['WORKER_PROCESSES'].to_i +listen ENV.fetch('LISTEN_ON', nil) +timeout 30 +preload_app true +GC.respond_to?(:copy_on_write_friendly=) && GC.copy_on_write_friendly = true + +check_client_connection false + +before_fork do |server, worker| + defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.disconnect! + + old_pid = "#{server.config[:pid]}.oldbin" + if old_pid != server.pid + begin + sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU + Process.kill(sig, File.read(old_pid).to_i) + rescue Errno::ENOENT, Errno::ESRCH + # do nothing + end + end +end + +after_fork do |_server, _worker| + defined?(ActiveRecord::Base) && ActiveRecord::Base.establish_connection +end diff --git a/source/db/migrate/20250811120000_create_posts.rb b/source/db/migrate/20250811120000_create_posts.rb new file mode 100644 index 000000000..799d3ab03 --- /dev/null +++ b/source/db/migrate/20250811120000_create_posts.rb @@ -0,0 +1,13 @@ +class CreatePosts < ActiveRecord::Migration[8.0] + def change + create_table :posts do |t| + t.string :title, null: false + t.integer :upvotes, null: false, default: 0 + t.integer :downvotes, null: false, default: 0 + + t.timestamps + end + end +end + + diff --git a/source/db/schema.rb b/source/db/schema.rb new file mode 100644 index 000000000..89f24490e --- /dev/null +++ b/source/db/schema.rb @@ -0,0 +1,24 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# 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[8.0].define(version: 2025_08_11_120000) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "posts", force: :cascade do |t| + t.string "title", null: false + t.integer "upvotes", default: 0, null: false + t.integer "downvotes", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end +end diff --git a/source/db/seeds.rb b/source/db/seeds.rb new file mode 100644 index 000000000..8f5d30964 --- /dev/null +++ b/source/db/seeds.rb @@ -0,0 +1,18 @@ +# This file should contain all the record creation needed to seed the database with its default values. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end + +# Create test posts with different vote patterns to demonstrate Wilson score ranking +Post.create!([ + { title: "High volume, good ratio (600/400)", upvotes: 600, downvotes: 400 }, + { title: "Low volume, good ratio (6/4)", upvotes: 6, downvotes: 4 }, + { title: "Low volume, excellent ratio (9/1)", upvotes: 9, downvotes: 1 }, + { title: "High volume, excellent ratio (900/100)", upvotes: 900, downvotes: 100 }, + { title: "No votes yet", upvotes: 0, downvotes: 0 }, + { title: "Mixed votes (50/50)", upvotes: 50, downvotes: 50 } +]) diff --git a/source/lib/assets/.keep b/source/lib/assets/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/lib/tasks/.keep b/source/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/log/.keep b/source/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/public/404.html b/source/public/404.html new file mode 100644 index 000000000..2be3af26f --- /dev/null +++ b/source/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/source/public/422.html b/source/public/422.html new file mode 100644 index 000000000..c08eac0d1 --- /dev/null +++ b/source/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/source/public/500.html b/source/public/500.html new file mode 100644 index 000000000..78a030af2 --- /dev/null +++ b/source/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/source/public/apple-touch-icon-precomposed.png b/source/public/apple-touch-icon-precomposed.png new file mode 100644 index 000000000..e69de29bb diff --git a/source/public/apple-touch-icon.png b/source/public/apple-touch-icon.png new file mode 100644 index 000000000..e69de29bb diff --git a/source/public/favicon.ico b/source/public/favicon.ico new file mode 100644 index 000000000..e69de29bb diff --git a/source/public/robots.txt b/source/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/source/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/source/spec/controllers/api/v1/posts_controller_spec.rb b/source/spec/controllers/api/v1/posts_controller_spec.rb new file mode 100644 index 000000000..54a08072a --- /dev/null +++ b/source/spec/controllers/api/v1/posts_controller_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +RSpec.describe Api::V1::PostsController, type: :controller do + describe 'GET #index' do + it 'returns posts ordered by score as JSON' do + post1 = Post.create!(title: 'Low Score', upvotes: 5, downvotes: 1) + post2 = Post.create!(title: 'High Score', upvotes: 50, downvotes: 5) + + get :index + + expect(response).to have_http_status(:ok) + expect(response.content_type).to include('application/json') + + json = JSON.parse(response.body) + expect(json.length).to eq(2) + expect(json.first['title']).to eq('High Score') + expect(json.last['title']).to eq('Low Score') + end + + it 'includes all required fields in JSON response' do + post = Post.create!(title: 'Test Post', upvotes: 10, downvotes: 2) + + get :index + + json = JSON.parse(response.body) + post_data = json.first + + expect(post_data).to include( + 'id', 'title', 'upvotes', 'downvotes', 'total_votes', 'score' + ) + expect(post_data['title']).to eq('Test Post') + expect(post_data['upvotes']).to eq(10) + expect(post_data['downvotes']).to eq(2) + expect(post_data['total_votes']).to eq(12) + expect(post_data['score']).to be_a(Float) + end + end + + describe 'POST #upvote' do + let(:test_post) { Post.create!(title: 'Test Post', upvotes: 5, downvotes: 2) } + + it 'increments upvotes' do + expect { + post :upvote, params: { id: test_post.id } + }.to change { test_post.reload.upvotes }.by(1) + end + + it 'returns updated post as JSON' do + post :upvote, params: { id: test_post.id } + + expect(response).to have_http_status(:ok) + expect(response.content_type).to include('application/json') + + json = JSON.parse(response.body) + expect(json['upvotes']).to eq(6) + expect(json['downvotes']).to eq(2) + expect(json['total_votes']).to eq(8) + end + + it 'returns 404 for non-existent post' do + post :upvote, params: { id: 99999 } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST #downvote' do + let(:test_post) { Post.create!(title: 'Test Post', upvotes: 5, downvotes: 2) } + + it 'increments downvotes' do + expect { + post :downvote, params: { id: test_post.id } + }.to change { test_post.reload.downvotes }.by(1) + end + + it 'returns updated post as JSON' do + post :downvote, params: { id: test_post.id } + + expect(response).to have_http_status(:ok) + expect(response.content_type).to include('application/json') + + json = JSON.parse(response.body) + expect(json['upvotes']).to eq(5) + expect(json['downvotes']).to eq(3) + expect(json['total_votes']).to eq(8) + end + + it 'returns 404 for non-existent post' do + post :downvote, params: { id: 99999 } + + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/source/spec/controllers/posts_controller_spec.rb b/source/spec/controllers/posts_controller_spec.rb new file mode 100644 index 000000000..e7b259d93 --- /dev/null +++ b/source/spec/controllers/posts_controller_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe PostsController, type: :controller do + describe 'GET #index' do + it 'assigns posts ordered by score' do + post1 = Post.create!(title: 'Low Score', upvotes: 5, downvotes: 1) + post2 = Post.create!(title: 'High Score', upvotes: 50, downvotes: 5) + + get :index + + expect(assigns(:posts)).to eq([post2, post1]) + end + + it 'renders the index template' do + get :index + expect(response).to render_template(:index) + end + + it 'returns success status' do + get :index + expect(response).to have_http_status(:ok) + end + + it 'handles empty posts list' do + get :index + expect(assigns(:posts)).to eq([]) + end + end +end diff --git a/source/spec/factories/.keep b/source/spec/factories/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/spec/factories/posts.rb b/source/spec/factories/posts.rb new file mode 100644 index 000000000..e6c36dfc9 --- /dev/null +++ b/source/spec/factories/posts.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :post do + sequence(:title) { |n| "Test Post #{n}" } + upvotes { 0 } + downvotes { 0 } + end +end diff --git a/source/spec/features/posts_feature_spec.rb b/source/spec/features/posts_feature_spec.rb new file mode 100644 index 000000000..bb143e5ab --- /dev/null +++ b/source/spec/features/posts_feature_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.feature 'Posts', type: :feature do + scenario 'User views posts list' do + post1 = Post.create!(title: 'First Post', upvotes: 10, downvotes: 2) + post2 = Post.create!(title: 'Second Post', upvotes: 5, downvotes: 1) + + visit '/posts' + + expect(page).to have_content('Posts') + expect(page).to have_content('First Post') + expect(page).to have_content('Second Post') + expect(page).to have_content('10') # upvotes + expect(page).to have_content('2') # downvotes + end + + scenario 'User sees posts ordered by score' do + low_score = Post.create!(title: 'Low Score Post', upvotes: 5, downvotes: 1) + high_score = Post.create!(title: 'High Score Post', upvotes: 50, downvotes: 5) + + visit '/posts' + + # Check that posts are displayed + expect(page).to have_content('High Score Post') + expect(page).to have_content('Low Score Post') + end + + scenario 'User sees empty state when no posts exist' do + visit '/posts' + + expect(page).to have_content('Posts') + expect(page).not_to have_content('data-post-id') + end + + scenario 'User sees upvote and downvote buttons' do + post = Post.create!(title: 'Test Post', upvotes: 5, downvotes: 2) + + visit '/posts' + + within("tr[data-post-id='#{post.id}']") do + expect(page).to have_button('👍 Upvote') + expect(page).to have_button('👎 Downvote') + end + end + + scenario 'User sees proper styling on buttons' do + post = Post.create!(title: 'Test Post', upvotes: 5, downvotes: 2) + + visit '/posts' + + within("tr[data-post-id='#{post.id}']") do + expect(page).to have_css('.btn.btn-success', text: '👍 Upvote') + expect(page).to have_css('.btn.btn-danger', text: '👎 Downvote') + end + end + + scenario 'User sees vote counts and scores' do + post = Post.create!(title: 'Test Post', upvotes: 15, downvotes: 3) + + visit '/posts' + + within("tr[data-post-id='#{post.id}']") do + expect(page).to have_content('15') # upvotes + expect(page).to have_content('3') # downvotes + expect(page).to have_content('18') # total votes + expect(page).to have_content(post.score.to_f.round(6).to_s) # score + end + end +end diff --git a/source/spec/models/post_spec.rb b/source/spec/models/post_spec.rb new file mode 100644 index 000000000..9d5a5e94a --- /dev/null +++ b/source/spec/models/post_spec.rb @@ -0,0 +1,121 @@ +require 'rails_helper' + +RSpec.describe Post, type: :model do + describe 'validations' do + it 'is valid with a title' do + post = Post.new(title: 'Test Post') + expect(post).to be_valid + end + + it 'is invalid without a title' do + post = Post.new(title: nil) + expect(post).not_to be_valid + expect(post.errors[:title]).to include("Title cannot be blank") + end + + it 'has default values for upvotes and downvotes' do + post = Post.create!(title: 'Test Post') + expect(post.upvotes).to eq(0) + expect(post.downvotes).to eq(0) + end + end + + describe '#total_votes' do + it 'returns sum of upvotes and downvotes' do + post = Post.new(upvotes: 10, downvotes: 5) + expect(post.total_votes).to eq(15) + end + + it 'returns zero when no votes' do + post = Post.new(upvotes: 0, downvotes: 0) + expect(post.total_votes).to eq(0) + end + end + + describe '#score' do + context 'when no votes' do + it 'returns zero' do + post = Post.new(title: 'No votes') + expect(post.score).to eq(0.0) + end + end + + context 'with positive vote difference' do + it 'returns positive score' do + post = Post.new(title: 'Positive votes', upvotes: 10, downvotes: 2) + expect(post.score).to be > 0 + end + + it 'higher vote difference results in higher score' do + high_diff = Post.new(title: 'High difference', upvotes: 100, downvotes: 10) + low_diff = Post.new(title: 'Low difference', upvotes: 10, downvotes: 1) + expect(high_diff.score).to be > low_diff.score + end + end + + context 'with negative vote difference' do + it 'returns negative score' do + post = Post.new(title: 'Negative votes', upvotes: 2, downvotes: 10) + expect(post.score).to be < 0 + end + end + + context 'with equal votes' do + it 'returns zero score' do + post = Post.new(title: 'Equal votes', upvotes: 5, downvotes: 5) + expect(post.score).to eq(0.0) + end + end + + context 'time decay' do + it 'newer posts have higher scores than older posts with same votes' do + old_post = Post.create!(title: 'Old post', upvotes: 50, downvotes: 10, created_at: 5.days.ago) + new_post = Post.create!(title: 'New post', upvotes: 50, downvotes: 10, created_at: 1.hour.ago) + expect(new_post.score).to be > old_post.score + end + end + end + + describe '.ordered_by_score' do + it 'orders posts by logarithmic score in descending order' do + post1 = Post.create!(title: 'Low score', upvotes: 5, downvotes: 1) + post2 = Post.create!(title: 'High score', upvotes: 50, downvotes: 5) + post3 = Post.create!(title: 'Negative score', upvotes: 1, downvotes: 10) + + ordered_posts = Post.ordered_by_score.to_a + expect(ordered_posts.first).to eq(post2) + expect(ordered_posts.last).to eq(post3) + end + + it 'handles posts with zero votes' do + post1 = Post.create!(title: 'No votes') + post2 = Post.create!(title: 'Some votes', upvotes: 5, downvotes: 2) + + ordered_posts = Post.ordered_by_score.to_a + expect(ordered_posts.first).to eq(post2) + expect(ordered_posts.last).to eq(post1) + end + end + + describe 'database constraints' do + it 'enforces non-null title' do + expect { + Post.create!(title: nil) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'enforces non-null upvotes' do + expect { + Post.create!(title: 'Test', upvotes: nil) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'enforces non-null downvotes' do + expect { + Post.create!(title: 'Test', downvotes: nil) + }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end + + diff --git a/source/spec/rails_helper.rb b/source/spec/rails_helper.rb new file mode 100644 index 000000000..eb4b5445e --- /dev/null +++ b/source/spec/rails_helper.rb @@ -0,0 +1,30 @@ +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +abort("The Rails environment is running in production mode!") if Rails.env.production? +require 'rspec/rails' +require 'capybara/rspec' + +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } + +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + puts e.to_s.strip + exit 1 +end + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + # Capybara configuration + config.include Capybara::DSL, type: :feature + config.include Capybara::RSpecMatchers, type: :feature +end + +# Capybara configuration +Capybara.default_driver = :rack_test +Capybara.javascript_driver = :selenium_chrome_headless +Capybara.server = :puma, { Silent: true } diff --git a/source/spec/requests/posts_spec.rb b/source/spec/requests/posts_spec.rb new file mode 100644 index 000000000..d002c9f46 --- /dev/null +++ b/source/spec/requests/posts_spec.rb @@ -0,0 +1,178 @@ +require 'rails_helper' + +RSpec.describe 'Posts', type: :request do + describe 'GET /posts' do + it 'returns posts page with ordered posts' do + post1 = Post.create!(title: 'First Post', upvotes: 10, downvotes: 2) + post2 = Post.create!(title: 'Second Post', upvotes: 5, downvotes: 1) + + get '/posts' + + expect(response).to have_http_status(:ok) + expect(response.body).to include('First Post') + expect(response.body).to include('Second Post') + expect(response.body).to include('10') # upvotes + expect(response.body).to include('2') # downvotes + end + + it 'displays posts in score order' do + low_score = Post.create!(title: 'Low Score', upvotes: 5, downvotes: 1) + high_score = Post.create!(title: 'High Score', upvotes: 50, downvotes: 5) + + get '/posts' + + expect(response.body).to include('High Score') + expect(response.body).to include('Low Score') + end + + it 'handles empty posts list' do + get '/posts' + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Posts') + expect(response.body).not_to include('data-post-id') + end + end +end + +RSpec.describe 'API::V1::Posts', type: :request do + describe 'GET /api/v1/posts' do + it 'returns posts ordered by Reddit-style logarithmic score' do + high_score = Post.create!(title: 'High score post', upvotes: 100, downvotes: 10) + medium_score = Post.create!(title: 'Medium score post', upvotes: 20, downvotes: 5) + low_score = Post.create!(title: 'Low score post', upvotes: 5, downvotes: 1) + negative_score = Post.create!(title: 'Negative score post', upvotes: 2, downvotes: 10) + + get '/api/v1/posts' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json.length).to eq(4) + + # Verify ordering by score (highest first) + scores = json.map { |post| post['score'] } + expect(scores).to eq(scores.sort.reverse) + + # Verify the highest scoring post is first + expect(json.first['title']).to eq('High score post') + end + + it 'returns empty array when no posts exist' do + get '/api/v1/posts' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to eq([]) + end + + it 'includes all required fields in response' do + post = Post.create!(title: 'Test Post', upvotes: 10, downvotes: 2) + + get '/api/v1/posts' + + json = JSON.parse(response.body) + post_data = json.find { |p| p['id'] == post.id } + + expect(post_data).to include( + 'id', 'title', 'upvotes', 'downvotes', 'total_votes', 'score' + ) + expect(post_data['title']).to eq('Test Post') + expect(post_data['upvotes']).to eq(10) + expect(post_data['downvotes']).to eq(2) + expect(post_data['total_votes']).to eq(12) + expect(post_data['score']).to be_a(Float) + end + end + + describe 'POST /api/v1/posts/:id/upvote' do + it 'increments upvotes and returns updated post data' do + post_record = Post.create!(title: 'Test post', upvotes: 5, downvotes: 2) + + expect do + post "/api/v1/posts/#{post_record.id}/upvote" + end.to change { post_record.reload.upvotes }.by(1) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['upvotes']).to eq(6) + expect(json['downvotes']).to eq(2) + expect(json['total_votes']).to eq(8) + expect(json['score']).to be_a(Float) + end + + it 'returns 404 for non-existent post' do + post "/api/v1/posts/99999/upvote" + expect(response).to have_http_status(:not_found) + end + + it 'handles multiple upvotes correctly' do + post_record = Post.create!(title: 'Test post', upvotes: 0, downvotes: 0) + + # First upvote + post "/api/v1/posts/#{post_record.id}/upvote" + expect(response).to have_http_status(:ok) + + # Second upvote + post "/api/v1/posts/#{post_record.id}/upvote" + expect(response).to have_http_status(:ok) + + post_record.reload + expect(post_record.upvotes).to eq(2) + expect(post_record.downvotes).to eq(0) + end + end + + describe 'POST /api/v1/posts/:id/downvote' do + it 'increments downvotes and returns updated post data' do + post_record = Post.create!(title: 'Test post', upvotes: 5, downvotes: 2) + + expect do + post "/api/v1/posts/#{post_record.id}/downvote" + end.to change { post_record.reload.downvotes }.by(1) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['upvotes']).to eq(5) + expect(json['downvotes']).to eq(3) + expect(json['total_votes']).to eq(8) + expect(json['score']).to be_a(Float) + end + + it 'returns 404 for non-existent post' do + post "/api/v1/posts/99999/downvote" + expect(response).to have_http_status(:not_found) + end + + it 'handles multiple downvotes correctly' do + post_record = Post.create!(title: 'Test post', upvotes: 0, downvotes: 0) + + # First downvote + post "/api/v1/posts/#{post_record.id}/downvote" + expect(response).to have_http_status(:ok) + + # Second downvote + post "/api/v1/posts/#{post_record.id}/downvote" + expect(response).to have_http_status(:ok) + + post_record.reload + expect(post_record.upvotes).to eq(0) + expect(post_record.downvotes).to eq(2) + end + end + + describe 'database locking' do + it 'uses database locking for vote increments' do + post_record = Post.create!(title: 'Lock test', upvotes: 0, downvotes: 0) + + # Test that the controller uses with_lock by checking the behavior + post "/api/v1/posts/#{post_record.id}/upvote" + expect(response).to have_http_status(:ok) + + post_record.reload + expect(post_record.upvotes).to eq(1) + end + end +end diff --git a/source/spec/routing/posts_routing_spec.rb b/source/spec/routing/posts_routing_spec.rb new file mode 100644 index 000000000..e5f045ca6 --- /dev/null +++ b/source/spec/routing/posts_routing_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe 'Posts routing', type: :routing do + describe 'UI routes' do + it 'routes GET /posts to posts#index' do + expect(get: '/posts').to route_to('posts#index') + end + end + + describe 'API routes' do + it 'routes GET /api/v1/posts to api/v1/posts#index' do + expect(get: '/api/v1/posts').to route_to('api/v1/posts#index') + end + + it 'routes POST /api/v1/posts/:id/upvote to api/v1/posts#upvote' do + expect(post: '/api/v1/posts/1/upvote').to route_to('api/v1/posts#upvote', id: '1') + end + + it 'routes POST /api/v1/posts/:id/downvote to api/v1/posts#downvote' do + expect(post: '/api/v1/posts/1/downvote').to route_to('api/v1/posts#downvote', id: '1') + end + end +end diff --git a/source/spec/services/post_scoring_service_spec.rb b/source/spec/services/post_scoring_service_spec.rb new file mode 100644 index 000000000..209fe9d86 --- /dev/null +++ b/source/spec/services/post_scoring_service_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe PostScoringService, type: :service do + let(:service) { PostScoringService.new } + let(:post) { Post.create!(title: "Test Post", upvotes: 10, downvotes: 2) } + + describe '.calculate_score' do + it 'calculates score for a post' do + score = PostScoringService.calculate_score(post) + expect(score).to be_a(Float) + expect(score).to be > 0 + end + + it 'returns 0 for posts with no votes' do + post.update(upvotes: 0, downvotes: 0) + score = PostScoringService.calculate_score(post) + expect(score).to eq(0.0) + end + + it 'handles posts with only downvotes' do + post.update(upvotes: 0, downvotes: 5) + score = PostScoringService.calculate_score(post) + expect(score).to be < 0 + end + end + + describe '#total_votes' do + it 'calculates total votes correctly' do + expect(service.total_votes(post)).to eq(12) + end + end + + + + describe '.logarithmic_score_sql' do + it 'returns valid SQL' do + sql = PostScoringService.logarithmic_score_sql + expect(sql).to include('CASE') + expect(sql).to include('SIGN') + expect(sql).to include('LOG10') + end + end +end diff --git a/source/spec/spec_helper.rb b/source/spec/spec_helper.rb new file mode 100644 index 000000000..06e09a309 --- /dev/null +++ b/source/spec/spec_helper.rb @@ -0,0 +1,11 @@ +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups +end diff --git a/source/spec/support/factory_bot.rb b/source/spec/support/factory_bot.rb new file mode 100644 index 000000000..c7890e49c --- /dev/null +++ b/source/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/source/storage/.keep b/source/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/tmp/.keep b/source/tmp/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/tmp/pids/.keep b/source/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/tmp/storage/.keep b/source/tmp/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/vendor/.keep b/source/vendor/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/source/vendor/javascript/.keep b/source/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb