From 13992e1dbe79ed73e19a790c7ee231e13b4b1098 Mon Sep 17 00:00:00 2001 From: Mohammad Kanaan Date: Sun, 14 Jun 2026 14:12:37 +0300 Subject: [PATCH] feat: implement user authentication and session management --- .gitignore | 2 + app/controllers/application_controller.rb | 9 ++- app/controllers/sessions_controller.rb | 18 +++++- app/controllers/users_controller.rb | 17 +++++- app/models/session.rb | 22 +++++++ bruno/auth/login-invalid.bru | 35 +++++++++++ bruno/auth/login.bru | 64 +++++++++++++++++++++ bruno/auth/logout.bru | 38 ++++++++++++ bruno/auth/signup.bru | 60 +++++++++++++++++++ bruno/auth/unauthorized.bru | 28 +++++++++ bruno/bruno.json | 8 +++ bruno/chains/create-chain.bru | 49 ++++++++++++++++ bruno/chains/delete-chain.bru | 23 ++++++++ bruno/chains/get-chain.bru | 32 +++++++++++ bruno/chains/list-chains.bru | 32 +++++++++++ bruno/chains/update-chain.bru | 36 ++++++++++++ bruno/collection.bru | 22 +++++++ bruno/environments/Local.bru | 4 ++ bruno/price-reports/create-price-report.bru | 57 ++++++++++++++++++ bruno/price-reports/delete-price-report.bru | 23 ++++++++ bruno/price-reports/get-price-report.bru | 37 ++++++++++++ bruno/price-reports/list-price-reports.bru | 35 +++++++++++ bruno/price-reports/update-price-report.bru | 43 ++++++++++++++ bruno/products/create-product.bru | 52 +++++++++++++++++ bruno/products/delete-product.bru | 23 ++++++++ bruno/products/get-product.bru | 33 +++++++++++ bruno/products/list-products.bru | 33 +++++++++++ bruno/products/update-product.bru | 37 ++++++++++++ bruno/stores/create-store.bru | 56 ++++++++++++++++++ bruno/stores/delete-store.bru | 23 ++++++++ bruno/stores/get-store.bru | 35 +++++++++++ bruno/stores/list-stores.bru | 35 +++++++++++ bruno/stores/update-store.bru | 36 ++++++++++++ bruno/users/delete-user.bru | 26 +++++++++ bruno/users/get-user.bru | 34 +++++++++++ db/schema.rb | 14 ++++- sig/application_controller.rbs | 3 + test/fixtures/sessions.yml | 21 +++++++ test/models/session_test.rb | 21 +++++++ 39 files changed, 1170 insertions(+), 6 deletions(-) create mode 100644 bruno/auth/login-invalid.bru create mode 100644 bruno/auth/login.bru create mode 100644 bruno/auth/logout.bru create mode 100644 bruno/auth/signup.bru create mode 100644 bruno/auth/unauthorized.bru create mode 100644 bruno/bruno.json create mode 100644 bruno/chains/create-chain.bru create mode 100644 bruno/chains/delete-chain.bru create mode 100644 bruno/chains/get-chain.bru create mode 100644 bruno/chains/list-chains.bru create mode 100644 bruno/chains/update-chain.bru create mode 100644 bruno/collection.bru create mode 100644 bruno/environments/Local.bru create mode 100644 bruno/price-reports/create-price-report.bru create mode 100644 bruno/price-reports/delete-price-report.bru create mode 100644 bruno/price-reports/get-price-report.bru create mode 100644 bruno/price-reports/list-price-reports.bru create mode 100644 bruno/price-reports/update-price-report.bru create mode 100644 bruno/products/create-product.bru create mode 100644 bruno/products/delete-product.bru create mode 100644 bruno/products/get-product.bru create mode 100644 bruno/products/list-products.bru create mode 100644 bruno/products/update-product.bru create mode 100644 bruno/stores/create-store.bru create mode 100644 bruno/stores/delete-store.bru create mode 100644 bruno/stores/get-store.bru create mode 100644 bruno/stores/list-stores.bru create mode 100644 bruno/stores/update-store.bru create mode 100644 bruno/users/delete-user.bru create mode 100644 bruno/users/get-user.bru create mode 100644 sig/application_controller.rbs diff --git a/.gitignore b/.gitignore index 063233c..01ab006 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ # Ignore master key for decrypting credentials and more. /config/master.key + +.DS_Store diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 85005bc..29f81db 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,12 +10,17 @@ class ApplicationController < ActionController::API private def authenticate! token = request.headers["Authorization"]&.split(" ")&.last - @current_user = Session.find_by(token:)&.user + @current_session = Session.find_by(token: token) + raise Unauthorized unless @current_session - raise Unauthorized unless @current_user + @current_user = @current_session.user end def current_user @current_user end + + def current_session + @current_session + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index c1cf048..8075e8f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,12 +1,26 @@ class SessionsController < ApplicationController - before_action :authenticate_user, only: [:destroy] + skip_before_action :authenticate!, only: [ :create ] def create - user = User.find_by(email: params[:email]) + user = User.find_by(email: login_params[:email]) + unless user.present? && user.authenticate(login_params[:password]) + raise Unauthorized + end + + session = user.sessions.create! + + render_success(user: user, token: session.token) end def destroy + @current_session.destroy! + render_success(nil) + end + private + + def login_params + params.permit(:email, :password) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 8ae5318..9ccce95 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,15 @@ class UsersController < ApplicationController - def create + skip_before_action :authenticate!, only: [ :create ] + def create + user = User.new(user_params) + + if user.save + session = user.sessions.create! + render_success({ user: user, token: session.token }, :created) + else + render_error("Failed to create user", :unprocessable_entity, details: user.errors.full_messages) + end end def show @@ -10,4 +19,10 @@ class UsersController < ApplicationController def destroy end + + private + + def user_params + params.require(:user).permit(:name, :email, :password, :password_confirmation) + end end diff --git a/app/models/session.rb b/app/models/session.rb index cf376fb..174ee15 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,25 @@ +# == Schema Information +# +# Table name: sessions +# +# id :bigint not null, primary key +# ip_address :string +# token :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_sessions_on_token (token) UNIQUE +# index_sessions_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# class Session < ApplicationRecord + has_secure_token belongs_to :user end diff --git a/bruno/auth/login-invalid.bru b/bruno/auth/login-invalid.bru new file mode 100644 index 0000000..7d894eb --- /dev/null +++ b/bruno/auth/login-invalid.bru @@ -0,0 +1,35 @@ +meta { + name: Login - Invalid Credentials + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/login +} + +headers { + Content-Type: application/json +} + +body:json { + { + "email": "nonexistent@example.com", + "password": "wrongpassword" + } +} + +assert { + res.status: equals 401 + res.body.success: equals false +} + +tests { + test("Invalid login returns error envelope", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(false); + expect(data).to.have.property("error"); + expect(data.error).to.have.property("message"); + }); +} \ No newline at end of file diff --git a/bruno/auth/login.bru b/bruno/auth/login.bru new file mode 100644 index 0000000..f837eeb --- /dev/null +++ b/bruno/auth/login.bru @@ -0,0 +1,64 @@ +meta { + name: Login + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/login + body: json + auth: none +} + +headers { + Content-Type: application/json +} + +body:json { + { + "email": "{{userEmail}}", + "password": "password123" + } +} + +assert { + res.status: 200 + res.body.success: true + res("data.token"): isNotEmpty +} + +script:post-response { + const body = res.getBody(); + if (body && body.data && body.data.token) { + bru.setVar("authToken", body.data.token); + bru.setVar("userId", body.data.user.id); + } +} + +tests { + test("Login returns wrapped success response", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(true); + expect(data).to.have.property("data"); + }); + + test("Data contains user object and token string", function() { + const data = res.getBody(); + expect(data.data).to.have.property("user"); + expect(data.data).to.have.property("token"); + }); + + test("User object has correct shape", function() { + const data = res.getBody(); + expect(data.data.user).to.have.property("id"); + expect(data.data.user).to.have.property("email"); + expect(data.data.user).to.have.property("name"); + }); + + test("Token is a non-empty string", function() { + const data = res.getBody(); + expect(data.data.token).to.be.a("string"); + expect(data.data.token.length).to.be.greaterThan(0); + }); +} diff --git a/bruno/auth/logout.bru b/bruno/auth/logout.bru new file mode 100644 index 0000000..012b19a --- /dev/null +++ b/bruno/auth/logout.bru @@ -0,0 +1,38 @@ +meta { + name: Logout + type: http + seq: 3 +} + +delete { + url: {{baseUrl}}/logout + body: none + auth: none +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 + res.body.success: equals true +} + +script:post-response { + bru.deleteVar("authToken"); + bru.deleteVar("userId"); +} + +tests { + test("Logout returns { success: true }", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(true); + }); + + test("Logout response has no data envelope", function() { + const data = res.getBody(); + expect(data).to.not.have.property("data"); + }); +} diff --git a/bruno/auth/signup.bru b/bruno/auth/signup.bru new file mode 100644 index 0000000..1ff2f5d --- /dev/null +++ b/bruno/auth/signup.bru @@ -0,0 +1,60 @@ +meta { + name: Signup + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/signup + body: json + auth: none +} + +headers { + Content-Type: application/json +} + +body:json { + { + "user": { + "name": "Test User", + "email": "test@example.com", + "password": "password123", + "password_confirmation": "password123" + } + } +} + +assert { + res.status: equals 200 +} + +script:post-response { + const body = res.getBody(); + if (body && body.data && body.data.token) { + bru.setVar("authToken", body.data.token); + bru.setVar("userId", body.data.user.id); + bru.setVar("userEmail", "test@example.com"); + } +} + +tests { + test("Signup returns wrapped success response", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(true); + }); + + test("Signup response contains data envelope", function() { + const data = res.getBody(); + expect(data).to.have.property("data"); + }); + + test("Data contains user and token", function() { + const data = res.getBody(); + expect(data.data).to.have.property("user"); + expect(data.data).to.have.property("token"); + expect(data.data.user).to.have.property("id"); + expect(data.data.user).to.have.property("email"); + }); +} diff --git a/bruno/auth/unauthorized.bru b/bruno/auth/unauthorized.bru new file mode 100644 index 0000000..d2f7f60 --- /dev/null +++ b/bruno/auth/unauthorized.bru @@ -0,0 +1,28 @@ +meta { + name: Unauthorized Access + type: http + seq: 5 +} + +get { + url: {{baseUrl}}/users/{{userId}} +} + +headers { + Authorization: Bearer invalid_token_here +} + +assert { + res.status: equals 401 + res.body.success: equals false +} + +tests { + test("Invalid token returns 401 with error shape", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(false); + expect(data).to.have.property("error"); + expect(data.error).to.have.property("message"); + }); +} \ No newline at end of file diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..7ddd9a7 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,8 @@ +{ + "version": "1", + "name": "Price Point API", + "type": "collection", + "ignore": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/bruno/chains/create-chain.bru b/bruno/chains/create-chain.bru new file mode 100644 index 0000000..0da83c6 --- /dev/null +++ b/bruno/chains/create-chain.bru @@ -0,0 +1,49 @@ +meta { + name: Create Chain + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/chains + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "chain": { + "name": "Test Chain Store" + } + } +} + +assert { + res.status: equals 201 +} + +script:post-response { + const body = res.getBody(); + if (body && body.id) { + bru.setVar("chainId", body.id); + } +} + +tests { + test("Create chain returns chain object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Created chain has correct name", function() { + const data = res.getBody(); + expect(data.name).to.equal("Test Chain Store"); + }); +} \ No newline at end of file diff --git a/bruno/chains/delete-chain.bru b/bruno/chains/delete-chain.bru new file mode 100644 index 0000000..a2e328a --- /dev/null +++ b/bruno/chains/delete-chain.bru @@ -0,0 +1,23 @@ +meta { + name: Delete Chain + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/chains/{{chainId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 204 +} + +tests { + test("Delete chain returns 204 no content", function() { + expect(res.getStatus()).to.equal(204); + }); +} \ No newline at end of file diff --git a/bruno/chains/get-chain.bru b/bruno/chains/get-chain.bru new file mode 100644 index 0000000..accb29b --- /dev/null +++ b/bruno/chains/get-chain.bru @@ -0,0 +1,32 @@ +meta { + name: Get Chain + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/chains/{{chainId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("Get chain returns chain object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Chain id matches requested id", function() { + const data = res.getBody(); + expect(data.id).to.equal(Number(bru.getVar("chainId"))); + }); +} \ No newline at end of file diff --git a/bruno/chains/list-chains.bru b/bruno/chains/list-chains.bru new file mode 100644 index 0000000..e2333f9 --- /dev/null +++ b/bruno/chains/list-chains.bru @@ -0,0 +1,32 @@ +meta { + name: List Chains + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/chains +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("List chains returns an array", function() { + const data = res.getBody(); + expect(data).to.be.an("array"); + }); + + test("Each chain has correct shape", function() { + const data = res.getBody(); + data.forEach(function(chain) { + expect(chain).to.have.property("id"); + expect(chain).to.have.property("name"); + }); + }); +} \ No newline at end of file diff --git a/bruno/chains/update-chain.bru b/bruno/chains/update-chain.bru new file mode 100644 index 0000000..18d27f5 --- /dev/null +++ b/bruno/chains/update-chain.bru @@ -0,0 +1,36 @@ +meta { + name: Update Chain + type: http + seq: 4 +} + +patch { + url: {{baseUrl}}/chains/{{chainId}} + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "chain": { + "name": "Updated Chain Name" + } + } +} + +assert { + res.status: equals 200 +} + +tests { + test("Update chain returns updated object", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data.name).to.equal("Updated Chain Name"); + }); +} \ No newline at end of file diff --git a/bruno/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..e54229d --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,22 @@ +meta { + name: Price Point API +} + +headers { + Content-Type: application/json +} + +tests { + test("Response status is not a server error", function() { + expect(res.getStatus()).to.be.below(500); + }); + + test("Response time is under 2 seconds", function() { + expect(res.getResponseTime()).to.be.below(2000); + }); + + test("Response body is valid JSON", function() { + const body = res.getBody(); + expect(body).to.not.be.undefined; + }); +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru new file mode 100644 index 0000000..9658b7f --- /dev/null +++ b/bruno/environments/Local.bru @@ -0,0 +1,4 @@ +vars { + baseUrl: http://localhost:3000 + userEmail: test@example.com +} diff --git a/bruno/price-reports/create-price-report.bru b/bruno/price-reports/create-price-report.bru new file mode 100644 index 0000000..7b00d9f --- /dev/null +++ b/bruno/price-reports/create-price-report.bru @@ -0,0 +1,57 @@ +meta { + name: Create Price Report + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/products/{{productId}}/price_reports + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "price_report": { + "price": "9.99", + "discount_price": "7.99", + "store_id": {{storeId}} + } + } +} + +assert { + res.status: equals 201 +} + +script:post-response { + const body = res.getBody(); + if (body && body.id) { + bru.setVar("priceReportId", body.id); + } +} + +tests { + test("Create price report returns report object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("price"); + expect(data).to.have.property("discount_price"); + expect(data).to.have.property("store_id"); + expect(data).to.have.property("product_id"); + expect(data).to.have.property("user_id"); + expect(data).to.have.property("reported_at"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Price report has correct values", function() { + const data = res.getBody(); + expect(data.product_id).to.equal(Number(bru.getVar("productId"))); + expect(data.store_id).to.equal(Number(bru.getVar("storeId"))); + }); +} \ No newline at end of file diff --git a/bruno/price-reports/delete-price-report.bru b/bruno/price-reports/delete-price-report.bru new file mode 100644 index 0000000..e5a5a81 --- /dev/null +++ b/bruno/price-reports/delete-price-report.bru @@ -0,0 +1,23 @@ +meta { + name: Delete Price Report + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/price_reports/{{priceReportId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 204 +} + +tests { + test("Delete price report returns 204 no content", function() { + expect(res.getStatus()).to.equal(204); + }); +} \ No newline at end of file diff --git a/bruno/price-reports/get-price-report.bru b/bruno/price-reports/get-price-report.bru new file mode 100644 index 0000000..6bfa774 --- /dev/null +++ b/bruno/price-reports/get-price-report.bru @@ -0,0 +1,37 @@ +meta { + name: Get Price Report + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/price_reports/{{priceReportId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("Get price report returns report object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("price"); + expect(data).to.have.property("discount_price"); + expect(data).to.have.property("store_id"); + expect(data).to.have.property("product_id"); + expect(data).to.have.property("user_id"); + expect(data).to.have.property("reported_at"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Price report id matches requested id", function() { + const data = res.getBody(); + expect(data.id).to.equal(Number(bru.getVar("priceReportId"))); + }); +} \ No newline at end of file diff --git a/bruno/price-reports/list-price-reports.bru b/bruno/price-reports/list-price-reports.bru new file mode 100644 index 0000000..2726cd9 --- /dev/null +++ b/bruno/price-reports/list-price-reports.bru @@ -0,0 +1,35 @@ +meta { + name: List Price Reports + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/products/{{productId}}/price_reports +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("List price reports returns an array", function() { + const data = res.getBody(); + expect(data).to.be.an("array"); + }); + + test("Each price report has correct shape", function() { + const data = res.getBody(); + data.forEach(function(report) { + expect(report).to.have.property("id"); + expect(report).to.have.property("price"); + expect(report).to.have.property("store_id"); + expect(report).to.have.property("product_id"); + expect(report).to.have.property("user_id"); + }); + }); +} \ No newline at end of file diff --git a/bruno/price-reports/update-price-report.bru b/bruno/price-reports/update-price-report.bru new file mode 100644 index 0000000..9a94513 --- /dev/null +++ b/bruno/price-reports/update-price-report.bru @@ -0,0 +1,43 @@ +meta { + name: Update Price Report + type: http + seq: 4 +} + +patch { + url: {{baseUrl}}/price_reports/{{priceReportId}} + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "price_report": { + "price": "12.99", + "discount_price": "10.99" + } + } +} + +assert { + res.status: equals 200 +} + +tests { + test("Update price report returns updated object", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("price"); + expect(data).to.have.property("discount_price"); + }); + + test("Updated values are reflected", function() { + const data = res.getBody(); + expect(String(data.price)).to.equal("12.99"); + expect(String(data.discount_price)).to.equal("10.99"); + }); +} \ No newline at end of file diff --git a/bruno/products/create-product.bru b/bruno/products/create-product.bru new file mode 100644 index 0000000..c2215f2 --- /dev/null +++ b/bruno/products/create-product.bru @@ -0,0 +1,52 @@ +meta { + name: Create Product + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/products + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "product": { + "name": "Test Product", + "barcode": "1234567890123" + } + } +} + +assert { + res.status: equals 201 +} + +script:post-response { + const body = res.getBody(); + if (body && body.id) { + bru.setVar("productId", body.id); + } +} + +tests { + test("Create product returns product object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("barcode"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Created product has correct values", function() { + const data = res.getBody(); + expect(data.name).to.equal("Test Product"); + expect(data.barcode).to.equal("1234567890123"); + }); +} \ No newline at end of file diff --git a/bruno/products/delete-product.bru b/bruno/products/delete-product.bru new file mode 100644 index 0000000..cd58465 --- /dev/null +++ b/bruno/products/delete-product.bru @@ -0,0 +1,23 @@ +meta { + name: Delete Product + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/products/{{productId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 204 +} + +tests { + test("Delete product returns 204 no content", function() { + expect(res.getStatus()).to.equal(204); + }); +} \ No newline at end of file diff --git a/bruno/products/get-product.bru b/bruno/products/get-product.bru new file mode 100644 index 0000000..7823aad --- /dev/null +++ b/bruno/products/get-product.bru @@ -0,0 +1,33 @@ +meta { + name: Get Product + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/products/{{productId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("Get product returns product object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("barcode"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Product id matches requested id", function() { + const data = res.getBody(); + expect(data.id).to.equal(Number(bru.getVar("productId"))); + }); +} \ No newline at end of file diff --git a/bruno/products/list-products.bru b/bruno/products/list-products.bru new file mode 100644 index 0000000..4de3c1e --- /dev/null +++ b/bruno/products/list-products.bru @@ -0,0 +1,33 @@ +meta { + name: List Products + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/products +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("List products returns an array", function() { + const data = res.getBody(); + expect(data).to.be.an("array"); + }); + + test("Each product has correct shape", function() { + const data = res.getBody(); + data.forEach(function(product) { + expect(product).to.have.property("id"); + expect(product).to.have.property("name"); + expect(product).to.have.property("barcode"); + }); + }); +} \ No newline at end of file diff --git a/bruno/products/update-product.bru b/bruno/products/update-product.bru new file mode 100644 index 0000000..054f263 --- /dev/null +++ b/bruno/products/update-product.bru @@ -0,0 +1,37 @@ +meta { + name: Update Product + type: http + seq: 4 +} + +patch { + url: {{baseUrl}}/products/{{productId}} + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "product": { + "name": "Updated Product Name" + } + } +} + +assert { + res.status: equals 200 +} + +tests { + test("Update product returns updated object", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("barcode"); + expect(data.name).to.equal("Updated Product Name"); + }); +} \ No newline at end of file diff --git a/bruno/stores/create-store.bru b/bruno/stores/create-store.bru new file mode 100644 index 0000000..3de06aa --- /dev/null +++ b/bruno/stores/create-store.bru @@ -0,0 +1,56 @@ +meta { + name: Create Store + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/stores + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "store": { + "name": "Test Store Location", + "latitude": 40.7128, + "longitude": -74.006, + "chain_id": {{chainId}} + } + } +} + +assert { + res.status: equals 201 +} + +script:post-response { + const body = res.getBody(); + if (body && body.id) { + bru.setVar("storeId", body.id); + } +} + +tests { + test("Create store returns store object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("latitude"); + expect(data).to.have.property("longitude"); + expect(data).to.have.property("chain_id"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Created store has correct values", function() { + const data = res.getBody(); + expect(data.name).to.equal("Test Store Location"); + expect(data.chain_id).to.equal(Number(bru.getVar("chainId"))); + }); +} \ No newline at end of file diff --git a/bruno/stores/delete-store.bru b/bruno/stores/delete-store.bru new file mode 100644 index 0000000..6fd8b48 --- /dev/null +++ b/bruno/stores/delete-store.bru @@ -0,0 +1,23 @@ +meta { + name: Delete Store + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/stores/{{storeId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 204 +} + +tests { + test("Delete store returns 204 no content", function() { + expect(res.getStatus()).to.equal(204); + }); +} \ No newline at end of file diff --git a/bruno/stores/get-store.bru b/bruno/stores/get-store.bru new file mode 100644 index 0000000..cea30ac --- /dev/null +++ b/bruno/stores/get-store.bru @@ -0,0 +1,35 @@ +meta { + name: Get Store + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/stores/{{storeId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("Get store returns store object directly", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data).to.have.property("latitude"); + expect(data).to.have.property("longitude"); + expect(data).to.have.property("chain_id"); + expect(data).to.have.property("created_at"); + expect(data).to.have.property("updated_at"); + }); + + test("Store id matches requested id", function() { + const data = res.getBody(); + expect(data.id).to.equal(Number(bru.getVar("storeId"))); + }); +} \ No newline at end of file diff --git a/bruno/stores/list-stores.bru b/bruno/stores/list-stores.bru new file mode 100644 index 0000000..88ef25f --- /dev/null +++ b/bruno/stores/list-stores.bru @@ -0,0 +1,35 @@ +meta { + name: List Stores + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/stores +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 +} + +tests { + test("List stores returns an array", function() { + const data = res.getBody(); + expect(data).to.be.an("array"); + }); + + test("Each store has correct shape", function() { + const data = res.getBody(); + data.forEach(function(store) { + expect(store).to.have.property("id"); + expect(store).to.have.property("name"); + expect(store).to.have.property("latitude"); + expect(store).to.have.property("longitude"); + expect(store).to.have.property("chain_id"); + }); + }); +} \ No newline at end of file diff --git a/bruno/stores/update-store.bru b/bruno/stores/update-store.bru new file mode 100644 index 0000000..36dbe23 --- /dev/null +++ b/bruno/stores/update-store.bru @@ -0,0 +1,36 @@ +meta { + name: Update Store + type: http + seq: 4 +} + +patch { + url: {{baseUrl}}/stores/{{storeId}} + body: json +} + +headers { + Content-Type: application/json + Authorization: Bearer {{authToken}} +} + +body:json { + { + "store": { + "name": "Updated Store Name" + } + } +} + +assert { + res.status: equals 200 +} + +tests { + test("Update store returns updated object", function() { + const data = res.getBody(); + expect(data).to.have.property("id"); + expect(data).to.have.property("name"); + expect(data.name).to.equal("Updated Store Name"); + }); +} \ No newline at end of file diff --git a/bruno/users/delete-user.bru b/bruno/users/delete-user.bru new file mode 100644 index 0000000..b5c7ab7 --- /dev/null +++ b/bruno/users/delete-user.bru @@ -0,0 +1,26 @@ +meta { + name: Delete User + type: http + seq: 2 +} + +delete { + url: {{baseUrl}}/users/{{userId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 + res.body.success: equals true +} + +tests { + test("Delete user returns { success: true }", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(true); + }); +} \ No newline at end of file diff --git a/bruno/users/get-user.bru b/bruno/users/get-user.bru new file mode 100644 index 0000000..d3c2625 --- /dev/null +++ b/bruno/users/get-user.bru @@ -0,0 +1,34 @@ +meta { + name: Get User + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/users/{{userId}} +} + +headers { + Authorization: Bearer {{authToken}} +} + +assert { + res.status: equals 200 + res.body.success: equals true +} + +tests { + test("Get user returns wrapped success response", function() { + const data = res.getBody(); + expect(data).to.have.property("success"); + expect(data.success).to.equal(true); + expect(data).to.have.property("data"); + }); + + test("User data has correct shape", function() { + const data = res.getBody(); + expect(data.data).to.have.property("id"); + expect(data.data).to.have.property("email"); + expect(data.data).to.have.property("name"); + }); +} \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index af4127e..5316e46 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_30_204821) do +ActiveRecord::Schema[8.0].define(version: 2026_06_09_155729) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -81,6 +81,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_30_204821) do t.index ["barcode"], name: "index_products_on_barcode", unique: true end + create_table "sessions", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "token" + t.string "user_agent" + t.string "ip_address" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_sessions_on_token", unique: true + t.index ["user_id"], name: "index_sessions_on_user_id" + end + create_table "stores", force: :cascade do |t| t.string "name" t.float "latitude" @@ -118,6 +129,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_30_204821) do add_foreign_key "price_reports", "products" add_foreign_key "price_reports", "stores" add_foreign_key "price_reports", "users" + add_foreign_key "sessions", "users" add_foreign_key "stores", "chains" add_foreign_key "votes", "users" end diff --git a/sig/application_controller.rbs b/sig/application_controller.rbs new file mode 100644 index 0000000..489de6e --- /dev/null +++ b/sig/application_controller.rbs @@ -0,0 +1,3 @@ +class ApplicationController + @current_user: User +end diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml index 2a4c855..1fc9ada 100644 --- a/test/fixtures/sessions.yml +++ b/test/fixtures/sessions.yml @@ -1,5 +1,26 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# == Schema Information +# +# Table name: sessions +# +# id :bigint not null, primary key +# ip_address :string +# token :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_sessions_on_token (token) UNIQUE +# index_sessions_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# one: user: one token: MyString diff --git a/test/models/session_test.rb b/test/models/session_test.rb index 47ad906..c9c0982 100644 --- a/test/models/session_test.rb +++ b/test/models/session_test.rb @@ -1,3 +1,24 @@ +# == Schema Information +# +# Table name: sessions +# +# id :bigint not null, primary key +# ip_address :string +# token :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_sessions_on_token (token) UNIQUE +# index_sessions_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# require "test_helper" class SessionTest < ActiveSupport::TestCase