diff --git a/app/controllers/api/v1/places_controller.rb b/app/controllers/api/v1/places_controller.rb
new file mode 100644
index 00000000..2a8c1e72
--- /dev/null
+++ b/app/controllers/api/v1/places_controller.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class PlacesController < ApiController
+ before_action :set_place, only: [:show, :update, :destroy]
+
+ def index
+ @places = policy_scope(Place).includes(:tags)
+ @places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
+
+ render json: Api::PlaceSerializer.new(@places).serialize
+ end
+
+ def show
+ authorize @place
+ render json: Api::PlaceSerializer.new(@place).serialize
+ end
+
+ def create
+ @place = current_api_user.places.build(place_params)
+ authorize @place
+
+ if @place.save
+ add_tags if tag_ids.present?
+ render json: Api::PlaceSerializer.new(@place).serialize, status: :created
+ else
+ render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ authorize @place
+
+ if @place.update(place_params)
+ sync_tags if params[:place][:tag_ids]
+ render json: Api::PlaceSerializer.new(@place).serialize
+ else
+ render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ authorize @place
+ @place.destroy!
+ head :no_content
+ end
+
+ def nearby
+ authorize Place, :nearby?
+
+ unless params[:latitude].present? && params[:longitude].present?
+ return render json: { error: 'latitude and longitude are required' }, status: :bad_request
+ end
+
+ results = Places::NearbySearch.new(
+ latitude: params[:latitude].to_f,
+ longitude: params[:longitude].to_f,
+ radius: params[:radius]&.to_f || 0.5,
+ limit: params[:limit]&.to_i || 10
+ ).call
+
+ render json: { places: results }
+ end
+
+ private
+
+ def set_place
+ @place = current_api_user.places.find(params[:id])
+ end
+
+ def place_params
+ params.require(:place).permit(:name, :latitude, :longitude, :source)
+ end
+
+ def tag_ids
+ params.dig(:place, :tag_ids) || []
+ end
+
+ def add_tags
+ return if tag_ids.empty?
+
+ tags = current_api_user.tags.where(id: tag_ids)
+ @place.tags << tags
+ end
+
+ def sync_tags
+ tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
+ tags = current_api_user.tags.where(id: tag_ids_param)
+ @place.tags = tags
+ end
+ end
+ end
+end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 00000000..9a862a1a
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class TagsController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_tag, only: [:edit, :update, :destroy]
+
+ def index
+ @tags = policy_scope(Tag).ordered
+ authorize Tag
+ end
+
+ def new
+ @tag = current_user.tags.build
+ authorize @tag
+ end
+
+ def create
+ @tag = current_user.tags.build(tag_params)
+ authorize @tag
+
+ if @tag.save
+ redirect_to tags_path, notice: 'Tag was successfully created.'
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ authorize @tag
+ end
+
+ def update
+ authorize @tag
+
+ if @tag.update(tag_params)
+ redirect_to tags_path, notice: 'Tag was successfully updated.'
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ authorize @tag
+ @tag.destroy!
+ redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
+ end
+
+ private
+
+ def set_tag
+ @tag = current_user.tags.find(params[:id])
+ end
+
+ def tag_params
+ params.require(:tag).permit(:name, :icon, :color)
+ end
+end
diff --git a/app/models/place.rb b/app/models/place.rb
index e9fe3637..b2d7525c 100644
--- a/app/models/place.rb
+++ b/app/models/place.rb
@@ -12,7 +12,10 @@ class Place < ApplicationRecord
has_many :place_visits, dependent: :destroy
has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit
- validates :name, :lonlat, presence: true
+ validates :name, presence: true
+ validates :latitude, :longitude, presence: true
+
+ before_validation :build_lonlat, if: -> { latitude.present? && longitude.present? }
enum :source, { manual: 0, photon: 1 }
@@ -42,4 +45,10 @@ class Place < ApplicationRecord
def osm_type
geodata.dig('properties', 'osm_type')
end
+
+ private
+
+ def build_lonlat
+ self.lonlat = "POINT(#{longitude} #{latitude})"
+ end
end
diff --git a/app/policies/place_policy.rb b/app/policies/place_policy.rb
new file mode 100644
index 00000000..cc04a504
--- /dev/null
+++ b/app/policies/place_policy.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class PlacePolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.where(user: user)
+ end
+ end
+
+ def index?
+ true
+ end
+
+ def show?
+ owner?
+ end
+
+ def create?
+ true
+ end
+
+ def new?
+ create?
+ end
+
+ def update?
+ owner?
+ end
+
+ def edit?
+ update?
+ end
+
+ def destroy?
+ owner?
+ end
+
+ def nearby?
+ true
+ end
+
+ private
+
+ def owner?
+ record.user_id == user.id
+ end
+end
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
new file mode 100644
index 00000000..c812772a
--- /dev/null
+++ b/app/policies/tag_policy.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class TagPolicy < ApplicationPolicy
+ class Scope < Scope
+ def resolve
+ scope.where(user: user)
+ end
+ end
+
+ def index?
+ true
+ end
+
+ def show?
+ owner?
+ end
+
+ def create?
+ true
+ end
+
+ def new?
+ create?
+ end
+
+ def update?
+ owner?
+ end
+
+ def edit?
+ update?
+ end
+
+ def destroy?
+ owner?
+ end
+
+ private
+
+ def owner?
+ record.user_id == user.id
+ end
+end
diff --git a/app/serializers/api/place_serializer.rb b/app/serializers/api/place_serializer.rb
index ae91bbdb..db432128 100644
--- a/app/serializers/api/place_serializer.rb
+++ b/app/serializers/api/place_serializer.rb
@@ -1,27 +1,25 @@
# frozen_string_literal: true
-class Api::PlaceSerializer
- def initialize(place)
- @place = place
+module Api
+ class PlaceSerializer
+ include Alba::Resource
+
+ attributes :id, :name, :latitude, :longitude, :source, :created_at
+
+ attribute :icon do |place|
+ place.tags.first&.icon
+ end
+
+ attribute :color do |place|
+ place.tags.first&.color
+ end
+
+ many :tags do
+ attributes :id, :name, :icon, :color
+ end
+
+ attribute :visits_count do |place|
+ place.visits.count
+ end
end
-
- def call
- {
- id: place.id,
- name: place.name,
- longitude: place.lon,
- latitude: place.lat,
- city: place.city,
- country: place.country,
- source: place.source,
- geodata: place.geodata,
- created_at: place.created_at,
- updated_at: place.updated_at,
- reverse_geocoded_at: place.reverse_geocoded_at
- }
- end
-
- private
-
- attr_reader :place
end
diff --git a/app/services/places/nearby_search.rb b/app/services/places/nearby_search.rb
new file mode 100644
index 00000000..ef865797
--- /dev/null
+++ b/app/services/places/nearby_search.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Places
+ class NearbySearch
+ RADIUS_KM = 0.5
+ MAX_RESULTS = 10
+
+ def initialize(latitude:, longitude:, radius: RADIUS_KM, limit: MAX_RESULTS)
+ @latitude = latitude
+ @longitude = longitude
+ @radius = radius
+ @limit = limit
+ end
+
+ def call
+ return [] unless reverse_geocoding_enabled?
+
+ results = Geocoder.search(
+ [latitude, longitude],
+ limit: limit,
+ distance_sort: true,
+ radius: radius,
+ units: :km
+ )
+
+ format_results(results)
+ rescue StandardError => e
+ Rails.logger.error("Nearby places search error: #{e.message}")
+ []
+ end
+
+ private
+
+ attr_reader :latitude, :longitude, :radius, :limit
+
+ def reverse_geocoding_enabled?
+ DawarichSettings.reverse_geocoding_enabled?
+ end
+
+ def format_results(results)
+ results.map do |result|
+ properties = result.data['properties'] || {}
+ coordinates = result.data.dig('geometry', 'coordinates') || [longitude, latitude]
+
+ {
+ name: extract_name(result.data),
+ latitude: coordinates[1],
+ longitude: coordinates[0],
+ osm_id: properties['osm_id'],
+ osm_type: properties['osm_type'],
+ osm_key: properties['osm_key'],
+ osm_value: properties['osm_value'],
+ city: properties['city'],
+ country: properties['country'],
+ street: properties['street'],
+ housenumber: properties['housenumber'],
+ postcode: properties['postcode']
+ }
+ end
+ end
+
+ def extract_name(data)
+ properties = data['properties'] || {}
+
+ properties['name'] ||
+ [properties['street'], properties['housenumber']].compact.join(' ').presence ||
+ properties['city'] ||
+ 'Unknown Place'
+ end
+ end
+end
diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb
new file mode 100644
index 00000000..0ecdf099
--- /dev/null
+++ b/app/views/tags/_form.html.erb
@@ -0,0 +1,79 @@
+<%= form_with(model: tag, class: "space-y-4") do |f| %>
+ <% if tag.errors.any? %>
+
+
+
<%= pluralize(tag.errors.count, "error") %> prohibited this tag from being saved:
+
+ <% tag.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= f.label :name, class: "label" %>
+ <%= f.text_field :name, class: "input input-bordered w-full", placeholder: "Home, Work, Restaurant..." %>
+
+
+
+
+
+
+
+<% end %>
+
+
diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb
new file mode 100644
index 00000000..bf04f3b3
--- /dev/null
+++ b/app/views/tags/edit.html.erb
@@ -0,0 +1,12 @@
+
+
+
Edit Tag
+
Update your tag details
+
+
+
+
+ <%= render "form", tag: @tag %>
+
+
+
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb
new file mode 100644
index 00000000..535af9c6
--- /dev/null
+++ b/app/views/tags/index.html.erb
@@ -0,0 +1,57 @@
+
+
+
Tags
+ <%= link_to "New Tag", new_tag_path, class: "btn btn-primary" %>
+
+
+ <% if @tags.any? %>
+
+
+
+
+ | Icon |
+ Name |
+ Color |
+ Places Count |
+ Actions |
+
+
+
+ <% @tags.each do |tag| %>
+
+ | <%= tag.icon %> |
+ <%= tag.name %> |
+
+ <% if tag.color.present? %>
+
+ <% else %>
+ No color
+ <% end %>
+ |
+ <%= tag.places.count %> |
+
+
+ <%= link_to "Edit", edit_tag_path(tag), class: "btn btn-sm btn-ghost" %>
+ <%= button_to "Delete", tag_path(tag), method: :delete,
+ data: { turbo_confirm: "Are you sure?" },
+ class: "btn btn-sm btn-error" %>
+
+ |
+
+ <% end %>
+
+
+
+
+ <% else %>
+
+
+
No tags yet. Create your first tag to organize your places!
+ <%= link_to "Create Tag", new_tag_path, class: "btn btn-sm btn-primary mt-2" %>
+
+
+ <% end %>
+
diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb
new file mode 100644
index 00000000..3c489c56
--- /dev/null
+++ b/app/views/tags/new.html.erb
@@ -0,0 +1,12 @@
+
+
+
New Tag
+
Create a new tag to organize your places
+
+
+
+
+ <%= render "form", tag: @tag %>
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 60684cfb..7fea7016 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -56,6 +56,7 @@ Rails.application.routes.draw do
resources :places, only: %i[index destroy]
resources :exports, only: %i[index create destroy]
resources :trips
+ resources :tags, except: [:show]
# Family management routes (only if feature is enabled)
if DawarichSettings.family_feature_enabled?
@@ -120,6 +121,11 @@ Rails.application.routes.draw do
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
+ resources :places, only: %i[index show create update destroy] do
+ collection do
+ get 'nearby'
+ end
+ end
resources :locations, only: %i[index] do
collection do
get 'suggestions'
diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb
new file mode 100644
index 00000000..ea2ef61f
--- /dev/null
+++ b/spec/policies/tag_policy_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe TagPolicy, type: :policy do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:tag) { create(:tag, user: user) }
+ let(:other_tag) { create(:tag, user: other_user) }
+
+ describe 'index?' do
+ it 'allows any authenticated user' do
+ expect(TagPolicy.new(user, Tag).index?).to be true
+ end
+ end
+
+ describe 'create? and new?' do
+ it 'allows any authenticated user to create' do
+ new_tag = user.tags.build
+ expect(TagPolicy.new(user, new_tag).create?).to be true
+ expect(TagPolicy.new(user, new_tag).new?).to be true
+ end
+ end
+
+ describe 'show?, edit?, update?, destroy?' do
+ context 'when user owns the tag' do
+ it 'allows all actions' do
+ policy = TagPolicy.new(user, tag)
+ expect(policy.show?).to be true
+ expect(policy.edit?).to be true
+ expect(policy.update?).to be true
+ expect(policy.destroy?).to be true
+ end
+ end
+
+ context 'when user does not own the tag' do
+ it 'denies all actions' do
+ policy = TagPolicy.new(user, other_tag)
+ expect(policy.show?).to be false
+ expect(policy.edit?).to be false
+ expect(policy.update?).to be false
+ expect(policy.destroy?).to be false
+ end
+ end
+ end
+
+ describe 'Scope' do
+ let!(:user_tags) { create_list(:tag, 3, user: user) }
+ let!(:other_tags) { create_list(:tag, 2, user: other_user) }
+
+ it 'returns only user-owned tags' do
+ scope = TagPolicy::Scope.new(user, Tag).resolve
+ expect(scope).to match_array(user_tags)
+ expect(scope).not_to include(*other_tags)
+ end
+ end
+end
diff --git a/spec/requests/api/v1/places_spec.rb b/spec/requests/api/v1/places_spec.rb
new file mode 100644
index 00000000..6214286b
--- /dev/null
+++ b/spec/requests/api/v1/places_spec.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Places', type: :request do
+ let(:user) { create(:user) }
+ let!(:place) { create(:place, user: user, name: 'Home', latitude: 40.7128, longitude: -74.0060) }
+ let!(:tag) { create(:tag, user: user, name: 'Favorite') }
+ let(:headers) { { 'APIKEY' => user.api_key } }
+
+ describe 'GET /api/v1/places' do
+ it 'returns user places' do
+ get '/api/v1/places', headers: headers
+
+ expect(response).to have_http_status(:success)
+ json = JSON.parse(response.body)
+ expect(json.size).to eq(1)
+ expect(json.first['name']).to eq('Home')
+ end
+
+ it 'filters by tag_ids' do
+ tagged_place = create(:place, user: user)
+ create(:tagging, taggable: tagged_place, tag: tag)
+
+ get '/api/v1/places', params: { tag_ids: [tag.id] }, headers: headers
+
+ json = JSON.parse(response.body)
+ expect(json.size).to eq(1)
+ expect(json.first['id']).to eq(tagged_place.id)
+ end
+
+ it 'does not return other users places' do
+ other_user = create(:user)
+ create(:place, user: other_user, name: 'Private Place')
+
+ get '/api/v1/places', headers: headers
+
+ json = JSON.parse(response.body)
+ expect(json.map { |p| p['name'] }).not_to include('Private Place')
+ end
+ end
+
+ describe 'GET /api/v1/places/:id' do
+ it 'returns the place' do
+ get "/api/v1/places/#{place.id}", headers: headers
+
+ expect(response).to have_http_status(:success)
+ json = JSON.parse(response.body)
+ expect(json['name']).to eq('Home')
+ expect(json['latitude']).to eq(40.7128)
+ end
+
+ it 'returns 404 for other users place' do
+ other_place = create(:place, user: create(:user))
+
+ expect {
+ get "/api/v1/places/#{other_place.id}", headers: headers
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe 'POST /api/v1/places' do
+ let(:valid_params) do
+ {
+ place: {
+ name: 'Central Park',
+ latitude: 40.785091,
+ longitude: -73.968285,
+ source: 'manual',
+ tag_ids: [tag.id]
+ }
+ }
+ end
+
+ it 'creates a place' do
+ expect {
+ post '/api/v1/places', params: valid_params, headers: headers
+ }.to change(Place, :count).by(1)
+
+ expect(response).to have_http_status(:created)
+ json = JSON.parse(response.body)
+ expect(json['name']).to eq('Central Park')
+ end
+
+ it 'associates tags with the place' do
+ post '/api/v1/places', params: valid_params, headers: headers
+
+ place = Place.last
+ expect(place.tags).to include(tag)
+ end
+
+ it 'returns errors for invalid params' do
+ post '/api/v1/places', params: { place: { name: '' } }, headers: headers
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json = JSON.parse(response.body)
+ expect(json['errors']).to be_present
+ end
+ end
+
+ describe 'PATCH /api/v1/places/:id' do
+ it 'updates the place' do
+ patch "/api/v1/places/#{place.id}",
+ params: { place: { name: 'Updated Home' } },
+ headers: headers
+
+ expect(response).to have_http_status(:success)
+ expect(place.reload.name).to eq('Updated Home')
+ end
+
+ it 'updates tags' do
+ new_tag = create(:tag, user: user, name: 'Work')
+
+ patch "/api/v1/places/#{place.id}",
+ params: { place: { tag_ids: [new_tag.id] } },
+ headers: headers
+
+ expect(place.reload.tags).to contain_exactly(new_tag)
+ end
+
+ it 'prevents updating other users places' do
+ other_place = create(:place, user: create(:user))
+
+ expect {
+ patch "/api/v1/places/#{other_place.id}",
+ params: { place: { name: 'Hacked' } },
+ headers: headers
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe 'DELETE /api/v1/places/:id' do
+ it 'destroys the place' do
+ expect {
+ delete "/api/v1/places/#{place.id}", headers: headers
+ }.to change(Place, :count).by(-1)
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ it 'prevents deleting other users places' do
+ other_place = create(:place, user: create(:user))
+
+ expect {
+ delete "/api/v1/places/#{other_place.id}", headers: headers
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe 'GET /api/v1/places/nearby' do
+ before do
+ allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
+ end
+
+ it 'returns nearby places from geocoder', :vcr do
+ get '/api/v1/places/nearby',
+ params: { latitude: 40.7128, longitude: -74.0060 },
+ headers: headers
+
+ expect(response).to have_http_status(:success)
+ json = JSON.parse(response.body)
+ expect(json['places']).to be_an(Array)
+ end
+
+ it 'requires latitude and longitude' do
+ get '/api/v1/places/nearby', headers: headers
+
+ expect(response).to have_http_status(:bad_request)
+ json = JSON.parse(response.body)
+ expect(json['error']).to include('latitude and longitude')
+ end
+
+ it 'accepts custom radius and limit' do
+ service_double = instance_double(Places::NearbySearch)
+ allow(Places::NearbySearch).to receive(:new)
+ .with(latitude: 40.7128, longitude: -74.0060, radius: 1.0, limit: 5)
+ .and_return(service_double)
+ allow(service_double).to receive(:call).and_return([])
+
+ get '/api/v1/places/nearby',
+ params: { latitude: 40.7128, longitude: -74.0060, radius: 1.0, limit: 5 },
+ headers: headers
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe 'authentication' do
+ it 'requires API key for all endpoints' do
+ get '/api/v1/places'
+ expect(response).to have_http_status(:unauthorized)
+
+ post '/api/v1/places', params: { place: { name: 'Test' } }
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+end
diff --git a/spec/requests/tags_spec.rb b/spec/requests/tags_spec.rb
new file mode 100644
index 00000000..e267af16
--- /dev/null
+++ b/spec/requests/tags_spec.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe "Tags", type: :request do
+ let(:user) { create(:user) }
+ let(:tag) { create(:tag, user: user) }
+ let(:valid_attributes) { { name: 'Home', icon: 'đ ', color: '#4CAF50' } }
+ let(:invalid_attributes) { { name: '', icon: 'X', color: 'invalid' } }
+
+ before { sign_in user }
+
+ describe "GET /tags" do
+ it "returns success" do
+ get tags_path
+ expect(response).to be_successful
+ end
+
+ it "displays user's tags" do
+ tag1 = create(:tag, user: user, name: 'Work')
+ tag2 = create(:tag, user: user, name: 'Home')
+
+ get tags_path
+ expect(response.body).to include('Work')
+ expect(response.body).to include('Home')
+ end
+
+ it "does not display other users' tags" do
+ other_user = create(:user)
+ other_tag = create(:tag, user: other_user, name: 'Private')
+
+ get tags_path
+ expect(response.body).not_to include('Private')
+ end
+ end
+
+ describe "GET /tags/new" do
+ it "returns success" do
+ get new_tag_path
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /tags/:id/edit" do
+ it "returns success" do
+ get edit_tag_path(tag)
+ expect(response).to be_successful
+ end
+
+ it "prevents editing other users' tags" do
+ other_tag = create(:tag, user: create(:user))
+
+ expect {
+ get edit_tag_path(other_tag)
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe "POST /tags" do
+ context "with valid parameters" do
+ it "creates a new tag" do
+ expect {
+ post tags_path, params: { tag: valid_attributes }
+ }.to change(Tag, :count).by(1)
+ end
+
+ it "redirects to tags index" do
+ post tags_path, params: { tag: valid_attributes }
+ expect(response).to redirect_to(tags_path)
+ end
+
+ it "associates tag with current user" do
+ post tags_path, params: { tag: valid_attributes }
+ expect(Tag.last.user).to eq(user)
+ end
+ end
+
+ context "with invalid parameters" do
+ it "does not create a new tag" do
+ expect {
+ post tags_path, params: { tag: invalid_attributes }
+ }.not_to change(Tag, :count)
+ end
+
+ it "returns unprocessable entity status" do
+ post tags_path, params: { tag: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe "PATCH /tags/:id" do
+ context "with valid parameters" do
+ let(:new_attributes) { { name: 'Updated Name', color: '#FF0000' } }
+
+ it "updates the tag" do
+ patch tag_path(tag), params: { tag: new_attributes }
+ tag.reload
+ expect(tag.name).to eq('Updated Name')
+ expect(tag.color).to eq('#FF0000')
+ end
+
+ it "redirects to tags index" do
+ patch tag_path(tag), params: { tag: new_attributes }
+ expect(response).to redirect_to(tags_path)
+ end
+ end
+
+ context "with invalid parameters" do
+ it "returns unprocessable entity status" do
+ patch tag_path(tag), params: { tag: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ it "prevents updating other users' tags" do
+ other_tag = create(:tag, user: create(:user))
+
+ expect {
+ patch tag_path(other_tag), params: { tag: { name: 'Hacked' } }
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe "DELETE /tags/:id" do
+ it "destroys the tag" do
+ tag_to_delete = create(:tag, user: user)
+
+ expect {
+ delete tag_path(tag_to_delete)
+ }.to change(Tag, :count).by(-1)
+ end
+
+ it "redirects to tags index" do
+ delete tag_path(tag)
+ expect(response).to redirect_to(tags_path)
+ end
+
+ it "prevents deleting other users' tags" do
+ other_tag = create(:tag, user: create(:user))
+
+ expect {
+ delete tag_path(other_tag)
+ }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context "when not authenticated" do
+ before { sign_out user }
+
+ it "redirects to sign in for index" do
+ get tags_path
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it "redirects to sign in for new" do
+ get new_tag_path
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it "redirects to sign in for create" do
+ post tags_path, params: { tag: valid_attributes }
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+end