mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Add places management API and tags feature
This commit is contained in:
parent
69c8779164
commit
78851c5f98
15 changed files with 929 additions and 24 deletions
94
app/controllers/api/v1/places_controller.rb
Normal file
94
app/controllers/api/v1/places_controller.rb
Normal file
|
|
@ -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
|
||||
57
app/controllers/tags_controller.rb
Normal file
57
app/controllers/tags_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
47
app/policies/place_policy.rb
Normal file
47
app/policies/place_policy.rb
Normal file
|
|
@ -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
|
||||
43
app/policies/tag_policy.rb
Normal file
43
app/policies/tag_policy.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
app/services/places/nearby_search.rb
Normal file
71
app/services/places/nearby_search.rb
Normal file
|
|
@ -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
|
||||
79
app/views/tags/_form.html.erb
Normal file
79
app/views/tags/_form.html.erb
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<%= form_with(model: tag, class: "space-y-4") do |f| %>
|
||||
<% if tag.errors.any? %>
|
||||
<div class="alert alert-error">
|
||||
<div>
|
||||
<h3 class="font-bold"><%= pluralize(tag.errors.count, "error") %> prohibited this tag from being saved:</h3>
|
||||
<ul class="list-disc list-inside">
|
||||
<% tag.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :name, class: "label" %>
|
||||
<%= f.text_field :name, class: "input input-bordered w-full", placeholder: "Home, Work, Restaurant..." %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :icon, class: "label" %>
|
||||
<div class="flex gap-2">
|
||||
<%= f.text_field :icon, class: "input input-bordered flex-1", placeholder: "🏠" %>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button type="button" tabindex="0" class="btn btn-outline">
|
||||
Pick Icon
|
||||
</button>
|
||||
<div tabindex="0" class="dropdown-content card card-compact w-72 p-2 shadow bg-base-100 z-10">
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-6 gap-2" data-action="click->icon-picker#select">
|
||||
<% %w[📍 🏠 🏢 🍴 ☕ 🏨 🎭 🏛️ 🌳 ⛰️ 🏖️ 🎪 🏪 🏬 🏭 🏯 🏰 🗼 🗽 ⛪ 🕌 🛕 🕍 ⛩️].each do |emoji| %>
|
||||
<button type="button" class="btn btn-sm text-2xl hover:bg-base-200" data-icon="<%= emoji %>">
|
||||
<%= emoji %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Click an emoji or paste any emoji</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :color, class: "label" %>
|
||||
<div class="flex gap-2 items-center">
|
||||
<%= f.color_field :color, class: "w-16 h-12 rounded cursor-pointer" %>
|
||||
<%= f.text_field :color, class: "input input-bordered flex-1", placeholder: "#FF5733" %>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Optional - Choose a color for this tag</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<div class="flex gap-2">
|
||||
<%= f.submit class: "btn btn-primary" %>
|
||||
<%= link_to "Cancel", tags_path, class: "btn btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const iconButtons = document.querySelectorAll('[data-icon]');
|
||||
const iconInput = document.querySelector('input[name="tag[icon]"]');
|
||||
|
||||
iconButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (iconInput) {
|
||||
iconInput.value = button.dataset.icon;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
12
app/views/tags/edit.html.erb
Normal file
12
app/views/tags/edit.html.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Edit Tag</h1>
|
||||
<p class="text-gray-600 mt-2">Update your tag details</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<%= render "form", tag: @tag %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
57
app/views/tags/index.html.erb
Normal file
57
app/views/tags/index.html.erb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Tags</h1>
|
||||
<%= link_to "New Tag", new_tag_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
||||
<% if @tags.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Icon</th>
|
||||
<th>Name</th>
|
||||
<th>Color</th>
|
||||
<th>Places Count</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @tags.each do |tag| %>
|
||||
<tr>
|
||||
<td class="text-2xl"><%= tag.icon %></td>
|
||||
<td class="font-semibold"><%= tag.name %></td>
|
||||
<td>
|
||||
<% if tag.color.present? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded" style="background-color: <%= tag.color %>;"></div>
|
||||
<span class="text-sm"><%= tag.color %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">No color</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td><%= tag.places.count %></td>
|
||||
<td class="text-right">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<p>No tags yet. Create your first tag to organize your places!</p>
|
||||
<%= link_to "Create Tag", new_tag_path, class: "btn btn-sm btn-primary mt-2" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
12
app/views/tags/new.html.erb
Normal file
12
app/views/tags/new.html.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">New Tag</h1>
|
||||
<p class="text-gray-600 mt-2">Create a new tag to organize your places</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<%= render "form", tag: @tag %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
57
spec/policies/tag_policy_spec.rb
Normal file
57
spec/policies/tag_policy_spec.rb
Normal file
|
|
@ -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
|
||||
197
spec/requests/api/v1/places_spec.rb
Normal file
197
spec/requests/api/v1/places_spec.rb
Normal file
|
|
@ -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
|
||||
166
spec/requests/tags_spec.rb
Normal file
166
spec/requests/tags_spec.rb
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue