mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
9 commits
5f1bab4914
...
f2e8d03fb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e8d03fb2 | ||
|
|
8b03b0c7f5 | ||
|
|
f969d5d3e6 | ||
|
|
708bca26eb | ||
|
|
45713f46dc | ||
|
|
3149767675 | ||
|
|
002b3bd635 | ||
|
|
f92f757a7a | ||
|
|
f9c93c0d3c |
46 changed files with 3594 additions and 348 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
# [0.29.2] - 2025-07-12
|
||||
|
||||
⚠️ If you were using RC, please run the following commands in the console, otherwise read on. ⚠️
|
||||
|
||||
```ruby
|
||||
# This will delete all tracks 👇
|
||||
Track.delete_all
|
||||
|
||||
# This will remove all tracks relations from points 👇
|
||||
Point.update_all(track_id: nil)
|
||||
|
||||
# This will create tracks for all users 👇
|
||||
User.find_each do |user|
|
||||
Tracks::CreateJob.perform_later(user.id, start_at: nil, end_at: nil, mode: :bulk)
|
||||
end
|
||||
```
|
||||
|
||||
## Added
|
||||
|
||||
- In the User Settings -> Background Jobs, you can now disable visits suggestions, which is enabled by default. It's a background task that runs every day around midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.
|
||||
|
|
@ -56,6 +71,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
## Fixed
|
||||
|
||||
- Swagger documentation is now valid again.
|
||||
- Invalid owntracks points are now ignored.
|
||||
|
||||
# [0.29.1] - 2025-07-02
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.settings,
|
||||
settings: current_api_user.safe_settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ class MapController < ApplicationController
|
|||
|
||||
def build_tracks
|
||||
track_ids = extract_track_ids
|
||||
TrackSerializer.new(current_user, track_ids).call
|
||||
|
||||
TracksSerializer.new(current_user, track_ids).call
|
||||
end
|
||||
|
||||
def calculate_distance
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function createTrackPopupContent(track, distanceUnit) {
|
|||
<strong>🕐 Start:</strong> ${startTime}<br>
|
||||
<strong>🏁 End:</strong> ${endTime}<br>
|
||||
<strong>⏱️ Duration:</strong> ${durationFormatted}<br>
|
||||
<strong>📏 Distance:</strong> ${formatDistance(track.distance, distanceUnit)}<br>
|
||||
<strong>📏 Distance:</strong> ${formatDistance(track.distance / 1000, distanceUnit)}<br>
|
||||
<strong>⚡ Avg Speed:</strong> ${formatSpeed(track.avg_speed, distanceUnit)}<br>
|
||||
<strong>⛰️ Elevation:</strong> +${track.elevation_gain || 0}m / -${track.elevation_loss || 0}m<br>
|
||||
<strong>📊 Max Alt:</strong> ${track.elevation_max || 0}m<br>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
queue_as :stats
|
||||
|
||||
def perform
|
||||
user_ids = User.pluck(:id)
|
||||
user_ids = User.active.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Owntracks::PointCreatingJob < ApplicationJob
|
|||
def perform(point_params, user_id)
|
||||
parsed_params = OwnTracks::Params.new(point_params).call
|
||||
|
||||
return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil?
|
||||
return if parsed_params.try(:[], :timestamp).nil? || parsed_params.try(:[], :lonlat).nil?
|
||||
return if point_exists?(parsed_params, user_id)
|
||||
|
||||
Point.create!(parsed_params.merge(user_id:))
|
||||
|
|
|
|||
|
|
@ -6,20 +6,7 @@ class Tracks::CreateJob < ApplicationJob
|
|||
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
|
||||
user = User.find(user_id)
|
||||
|
||||
# Translate mode parameter to Generator mode
|
||||
generator_mode = case mode
|
||||
when :daily then :daily
|
||||
when :none then :incremental
|
||||
else :bulk
|
||||
end
|
||||
|
||||
# Generate tracks and get the count of tracks created
|
||||
tracks_created = Tracks::Generator.new(
|
||||
user,
|
||||
start_at: start_at,
|
||||
end_at: end_at,
|
||||
mode: generator_mode
|
||||
).call
|
||||
tracks_created = Tracks::Generator.new(user, start_at:, end_at:, mode:).call
|
||||
|
||||
create_success_notification(user, tracks_created)
|
||||
rescue StandardError => e
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
# track.distance # => 5000 (meters stored in DB)
|
||||
# track.distance_in_unit('km') # => 5.0 (converted to km)
|
||||
# track.distance_in_unit('mi') # => 3.11 (converted to miles)
|
||||
# track.formatted_distance('km') # => "5.0 km"
|
||||
#
|
||||
module DistanceConvertible
|
||||
extend ActiveSupport::Concern
|
||||
|
|
@ -38,21 +37,11 @@ module DistanceConvertible
|
|||
distance.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def formatted_distance(unit, precision: 2)
|
||||
converted_distance = distance_in_unit(unit)
|
||||
"#{converted_distance.round(precision)} #{unit}"
|
||||
end
|
||||
|
||||
def distance_for_user(user)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
distance_in_unit(user_unit)
|
||||
end
|
||||
|
||||
def formatted_distance_for_user(user, precision: 2)
|
||||
user_unit = user.safe_settings.distance_unit
|
||||
formatted_distance(user_unit, precision: precision)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def convert_distance(distance_meters, unit)
|
||||
return 0.0 unless distance_meters.present?
|
||||
|
|
@ -66,10 +55,5 @@ module DistanceConvertible
|
|||
|
||||
distance_meters.to_f / conversion_factor
|
||||
end
|
||||
|
||||
def format_distance(distance_meters, unit, precision: 2)
|
||||
converted = convert_distance(distance_meters, unit)
|
||||
"#{converted.round(precision)} #{unit}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Point < ApplicationRecord
|
|||
after_create :set_country
|
||||
after_create_commit :broadcast_coordinates
|
||||
after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
|
||||
after_commit :recalculate_track, on: :update
|
||||
after_commit :recalculate_track, on: :update, if: -> { track.present? }
|
||||
|
||||
def self.without_raw_data
|
||||
select(column_names - ['raw_data'])
|
||||
|
|
@ -99,8 +99,6 @@ class Point < ApplicationRecord
|
|||
end
|
||||
|
||||
def recalculate_track
|
||||
return unless track.present?
|
||||
|
||||
track.recalculate_path_and_distance!
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TrackSerializer
|
||||
def initialize(user, track_ids)
|
||||
@user = user
|
||||
@track_ids = track_ids
|
||||
def initialize(track)
|
||||
@track = track
|
||||
end
|
||||
|
||||
def call
|
||||
return [] if track_ids.empty?
|
||||
|
||||
tracks = user.tracks
|
||||
.where(id: track_ids)
|
||||
.order(start_at: :asc)
|
||||
|
||||
tracks.map { |track| serialize_track_data(track) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :track_ids
|
||||
|
||||
def serialize_track_data(track)
|
||||
{
|
||||
id: track.id,
|
||||
start_at: track.start_at.iso8601,
|
||||
end_at: track.end_at.iso8601,
|
||||
distance: track.distance.to_i,
|
||||
avg_speed: track.avg_speed.to_f,
|
||||
duration: track.duration,
|
||||
elevation_gain: track.elevation_gain,
|
||||
elevation_loss: track.elevation_loss,
|
||||
elevation_max: track.elevation_max,
|
||||
elevation_min: track.elevation_min,
|
||||
original_path: track.original_path.to_s
|
||||
id: @track.id,
|
||||
start_at: @track.start_at.iso8601,
|
||||
end_at: @track.end_at.iso8601,
|
||||
distance: @track.distance.to_i,
|
||||
avg_speed: @track.avg_speed.to_f,
|
||||
duration: @track.duration,
|
||||
elevation_gain: @track.elevation_gain,
|
||||
elevation_loss: @track.elevation_loss,
|
||||
elevation_max: @track.elevation_max,
|
||||
elevation_min: @track.elevation_min,
|
||||
original_path: @track.original_path.to_s
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
22
app/serializers/tracks_serializer.rb
Normal file
22
app/serializers/tracks_serializer.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TracksSerializer
|
||||
def initialize(user, track_ids)
|
||||
@user = user
|
||||
@track_ids = track_ids
|
||||
end
|
||||
|
||||
def call
|
||||
return [] if track_ids.empty?
|
||||
|
||||
tracks = user.tracks
|
||||
.where(id: track_ids)
|
||||
.order(start_at: :asc)
|
||||
|
||||
tracks.map { |track| TrackSerializer.new(track).call }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :track_ids
|
||||
end
|
||||
|
|
@ -10,6 +10,8 @@ class OwnTracks::Params
|
|||
# rubocop:disable Metrics/MethodLength
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def call
|
||||
return unless valid_point?
|
||||
|
||||
{
|
||||
lonlat: "POINT(#{params[:lon]} #{params[:lat]})",
|
||||
battery: params[:batt],
|
||||
|
|
@ -84,4 +86,8 @@ class OwnTracks::Params
|
|||
def owntracks_point?
|
||||
params[:topic].present?
|
||||
end
|
||||
|
||||
def valid_point?
|
||||
params[:lon].present? && params[:lat].present? && params[:tst].present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ module Places
|
|||
end
|
||||
|
||||
def call
|
||||
geodata = Geocoder.search([@place.lat, @place.lon], units: :km, limit: 1, distance_sort: true).first
|
||||
geodata = Geocoder.search([place.lat, place.lon], units: :km, limit: 1, distance_sort: true).first
|
||||
|
||||
return if geodata.blank?
|
||||
|
||||
|
|
@ -15,21 +15,29 @@ module Places
|
|||
return if properties.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@place.name = properties['name'] if properties['name'].present?
|
||||
@place.city = properties['city'] if properties['city'].present?
|
||||
@place.country = properties['country'] if properties['country'].present?
|
||||
@place.geodata = geodata.data if DawarichSettings.store_geodata?
|
||||
@place.save!
|
||||
update_place_name(properties, geodata)
|
||||
|
||||
if properties['name'].present?
|
||||
@place
|
||||
.visits
|
||||
.where(name: Place::DEFAULT_NAME)
|
||||
.update_all(name: properties['name'])
|
||||
update_visits_name(properties) if properties['name'].present?
|
||||
|
||||
place
|
||||
end
|
||||
end
|
||||
|
||||
@place
|
||||
private
|
||||
|
||||
attr_reader :place
|
||||
|
||||
def update_place_name(properties, geodata)
|
||||
place.name = properties['name'] if properties['name'].present?
|
||||
place.city = properties['city'] if properties['city'].present?
|
||||
place.country = properties['country'] if properties['country'].present?
|
||||
place.geodata = geodata.data if DawarichSettings.store_geodata?
|
||||
|
||||
place.save!
|
||||
end
|
||||
|
||||
def update_visits_name(properties)
|
||||
place.visits.where(name: Place::DEFAULT_NAME).update_all(name: properties['name'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class Tracks::Generator
|
|||
Rails.logger.debug "Generator: created #{segments.size} segments"
|
||||
|
||||
tracks_created = 0
|
||||
|
||||
segments.each do |segment|
|
||||
track = create_track_from_segment(segment)
|
||||
tracks_created += 1 if track
|
||||
|
|
@ -146,10 +147,6 @@ class Tracks::Generator
|
|||
day.beginning_of_day.to_i..day.end_of_day.to_i
|
||||
end
|
||||
|
||||
def incremental_mode?
|
||||
mode == :incremental
|
||||
end
|
||||
|
||||
def clean_existing_tracks
|
||||
case mode
|
||||
when :bulk then clean_bulk_tracks
|
||||
|
|
|
|||
|
|
@ -36,12 +36,7 @@ class Tracks::IncrementalProcessor
|
|||
start_at = find_start_time
|
||||
end_at = find_end_time
|
||||
|
||||
Tracks::CreateJob.perform_later(
|
||||
user.id,
|
||||
start_at: start_at,
|
||||
end_at: end_at,
|
||||
mode: :none
|
||||
)
|
||||
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ module Tracks::Segmentation
|
|||
return true if time_diff_seconds > time_threshold_seconds
|
||||
|
||||
# Check distance threshold - convert km to meters to match frontend logic
|
||||
distance_km = calculate_distance_kilometers_between_points(previous_point, current_point)
|
||||
distance_km = calculate_km_distance_between_points(previous_point, current_point)
|
||||
distance_meters = distance_km * 1000 # Convert km to meters
|
||||
|
||||
return true if distance_meters > distance_threshold_meters
|
||||
|
|
@ -85,7 +85,7 @@ module Tracks::Segmentation
|
|||
false
|
||||
end
|
||||
|
||||
def calculate_distance_kilometers_between_points(point1, point2)
|
||||
def calculate_km_distance_between_points(point1, point2)
|
||||
lat1, lon1 = point_coordinates(point1)
|
||||
lat2, lon2 = point_coordinates(point2)
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ class Users::SafeSettings
|
|||
end
|
||||
|
||||
def distance_unit
|
||||
# km or mi
|
||||
settings.dig('maps', 'distance_unit')
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ Rails.application.configure do
|
|||
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
|
||||
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ Rails.application.configure do
|
|||
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
|
||||
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
|
||||
|
||||
config.action_mailer.default_url_options = { host: ENV['SMTP_DOMAIN'] }
|
||||
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
|
||||
config.hosts.concat(hosts) if hosts.present?
|
||||
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
|
|
|
|||
530
dawarich_user_scenarios.md
Normal file
530
dawarich_user_scenarios.md
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
# Dawarich User Scenarios Documentation
|
||||
|
||||
## Overview
|
||||
Dawarich is a self-hosted location history tracking application that allows users to import, visualize, and analyze their location data. This document describes all user scenarios for comprehensive test coverage.
|
||||
|
||||
## Application Context
|
||||
- **Purpose**: Self-hosted alternative to Google Timeline/Location History
|
||||
- **Tech Stack**: Rails 8, PostgreSQL, Hotwire (Turbo/Stimulus), Tailwind CSS with DaisyUI
|
||||
- **Key Features**: Location tracking, data visualization, import/export, statistics, visits detection
|
||||
- **Deployment**: Docker-based with self-hosted and cloud options
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication & User Management
|
||||
|
||||
### 1.1 User Registration (Non-Self-Hosted Mode)
|
||||
**Scenario**: New user registration process
|
||||
- **Entry Point**: Home page → Sign up link
|
||||
- **Steps**:
|
||||
1. Navigate to registration form
|
||||
2. Fill in email, password, password confirmation
|
||||
3. Complete CAPTCHA (if enabled)
|
||||
4. Submit registration
|
||||
5. Receive confirmation (if email verification enabled)
|
||||
- **Validation**: Email format, password strength, password confirmation match
|
||||
- **Success**: User created, redirected to sign-in or dashboard
|
||||
|
||||
### 1.2 User Sign In/Out
|
||||
**Scenario**: User authentication workflow
|
||||
- **Entry Point**: Home page → Sign in link
|
||||
- **Steps**:
|
||||
1. Navigate to sign-in form
|
||||
2. Enter email and password
|
||||
3. Optionally check "Remember me"
|
||||
4. Submit login
|
||||
5. Successful login redirects to map page
|
||||
- **Demo Mode**: Special demo credentials (demo@dawarich.app / password)
|
||||
- **Sign Out**: User can sign out from dropdown menu
|
||||
|
||||
### 1.3 Password Management
|
||||
**Scenario**: Password reset and change functionality
|
||||
- **Forgot Password**:
|
||||
1. Click "Forgot password" link
|
||||
2. Enter email address
|
||||
3. Receive reset email
|
||||
4. Follow reset link
|
||||
5. Set new password
|
||||
- **Change Password** (when signed in):
|
||||
1. Navigate to account settings
|
||||
2. Provide current password
|
||||
3. Enter new password and confirmation
|
||||
4. Save changes
|
||||
|
||||
### 1.4 Account Settings
|
||||
**Scenario**: User account management
|
||||
- **Entry Point**: User dropdown → Account
|
||||
- **Actions**:
|
||||
1. Update email address (requires current password)
|
||||
2. Change password
|
||||
3. View API key
|
||||
4. Generate new API key
|
||||
5. Theme selection (light/dark)
|
||||
- **Self-Hosted**: Limited registration options
|
||||
|
||||
---
|
||||
|
||||
## 2. Map Functionality & Visualization
|
||||
|
||||
### 2.1 Main Map Interface
|
||||
**Scenario**: Core location data visualization
|
||||
- **Entry Point**: Primary navigation → Map
|
||||
- **Features**:
|
||||
1. Interactive Leaflet map with multiple tile layers
|
||||
2. Time range selector (date/time inputs)
|
||||
3. Quick time range buttons (Today, Last 7 days, Last month)
|
||||
4. Navigation arrows for day-by-day browsing
|
||||
5. Real-time distance and points count display
|
||||
|
||||
### 2.2 Map Layers & Controls
|
||||
**Scenario**: Map customization and layer management
|
||||
- **Base Layers**:
|
||||
1. Switch between OpenStreetMap and OpenTopo
|
||||
2. Custom tile layer configuration
|
||||
- **Overlay Layers**:
|
||||
1. Toggle points display
|
||||
2. Toggle route lines
|
||||
3. Toggle heatmap
|
||||
4. Toggle fog of war
|
||||
5. Toggle areas
|
||||
6. Toggle visits
|
||||
- **Layer Control**: Expandable/collapsible layer panel
|
||||
|
||||
### 2.3 Map Data Display
|
||||
**Scenario**: Location data visualization options
|
||||
- **Points Rendering**:
|
||||
1. Raw mode (all points)
|
||||
2. Simplified mode (filtered by time/distance)
|
||||
3. Point clicking reveals details popup
|
||||
4. Battery level, altitude, velocity display
|
||||
- **Routes**:
|
||||
1. Polyline connections between points
|
||||
2. Speed-colored routes option
|
||||
3. Configurable route opacity
|
||||
4. Route segment distance display
|
||||
|
||||
### 2.4 Map Settings & Configuration
|
||||
**Scenario**: Map behavior customization
|
||||
- **Settings Available**:
|
||||
1. Route opacity (0-100%)
|
||||
2. Meters between routes (distance threshold)
|
||||
3. Minutes between routes (time threshold)
|
||||
4. Fog of war radius
|
||||
5. Speed color scale customization
|
||||
6. Points rendering mode
|
||||
- **Help Modals**: Contextual help for each setting
|
||||
|
||||
---
|
||||
|
||||
## 3. Location Data Import
|
||||
|
||||
### 3.1 Manual File Import
|
||||
**Scenario**: Import location data from various sources
|
||||
- **Entry Point**: Navigation → My data → Imports
|
||||
- **Supported Sources**:
|
||||
1. Google Semantic History (JSON files)
|
||||
2. Google Records (Records.json)
|
||||
3. Google Phone Takeout (mobile device JSON)
|
||||
4. OwnTracks (.rec files)
|
||||
5. GeoJSON files
|
||||
6. GPX track files
|
||||
- **Process**:
|
||||
1. Select source type
|
||||
2. Choose file(s) via file picker
|
||||
3. Upload and process (background job)
|
||||
4. Receive completion notification
|
||||
|
||||
### 3.2 Automatic File Watching
|
||||
**Scenario**: Automatic import from watched directories
|
||||
- **Setup**: Files placed in `/tmp/imports/watched/USER@EMAIL.TLD/`
|
||||
- **Process**: System scans hourly for new files
|
||||
- **Supported Formats**: GPX, JSON, REC files
|
||||
- **Notification**: User receives import completion notifications
|
||||
|
||||
### 3.3 Photo Integration Import
|
||||
**Scenario**: Import location data from photo EXIF data
|
||||
- **Immich Integration**:
|
||||
1. Configure Immich URL and API key in settings
|
||||
2. Trigger import job
|
||||
3. System extracts GPS data from photos
|
||||
4. Creates location points from photo metadata
|
||||
- **Photoprism Integration**:
|
||||
1. Configure Photoprism URL and API key
|
||||
2. Similar process to Immich
|
||||
3. Supports different date ranges
|
||||
|
||||
### 3.4 Import Management
|
||||
**Scenario**: View and manage import history
|
||||
- **Import List**: View all imports with status
|
||||
- **Import Details**: Points count, processing status, errors
|
||||
- **Import Actions**: View details, delete imports
|
||||
- **Progress Tracking**: Real-time progress updates via WebSocket
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Export
|
||||
|
||||
### 4.1 Export Creation
|
||||
**Scenario**: Export location data in various formats
|
||||
- **Entry Point**: Navigation → My data → Exports
|
||||
- **Export Types**:
|
||||
1. GeoJSON format (default)
|
||||
2. GPX format
|
||||
3. Complete user data archive (ZIP)
|
||||
- **Process**:
|
||||
1. Select export format
|
||||
2. Choose date range (optional)
|
||||
3. Submit export request
|
||||
4. Background processing
|
||||
5. Notification when complete
|
||||
|
||||
### 4.2 Export Management
|
||||
**Scenario**: Manage created exports
|
||||
- **Export List**: View all exports with details
|
||||
- **Export Actions**:
|
||||
1. Download completed exports
|
||||
2. Delete old exports
|
||||
3. View export status
|
||||
- **File Information**: Size, creation date, download links
|
||||
|
||||
### 4.3 Complete Data Export
|
||||
**Scenario**: Export all user data for backup/migration
|
||||
- **Trigger**: Settings → Users → Export data
|
||||
- **Content**: All user data, settings, files in ZIP format
|
||||
- **Use Case**: Account migration, data backup
|
||||
- **Process**: Background job, notification on completion
|
||||
|
||||
---
|
||||
|
||||
## 5. Statistics & Analytics
|
||||
|
||||
### 5.1 Statistics Dashboard
|
||||
**Scenario**: View travel statistics and analytics
|
||||
- **Entry Point**: Navigation → Stats
|
||||
- **Key Metrics**:
|
||||
1. Total distance traveled
|
||||
2. Total tracked points
|
||||
3. Countries visited
|
||||
4. Cities visited
|
||||
5. Reverse geocoding statistics
|
||||
- **Display**: Cards with highlighted numbers and units
|
||||
|
||||
### 5.2 Yearly/Monthly Breakdown
|
||||
**Scenario**: Detailed statistics by time period
|
||||
- **View Options**:
|
||||
1. Statistics by year
|
||||
2. Monthly breakdown within years
|
||||
3. Distance traveled per period
|
||||
4. Points tracked per period
|
||||
- **Actions**: Update statistics (background job)
|
||||
|
||||
### 5.3 Statistics Management
|
||||
**Scenario**: Update and manage statistics
|
||||
- **Manual Updates**:
|
||||
1. Update all statistics
|
||||
2. Update specific year/month
|
||||
3. Background job processing
|
||||
- **Automatic Updates**: Triggered by data imports
|
||||
|
||||
---
|
||||
|
||||
## 6. Trips Management
|
||||
|
||||
### 6.1 Trip Creation
|
||||
**Scenario**: Create and manage travel trips
|
||||
- **Entry Point**: Navigation → Trips → New trip
|
||||
- **Trip Properties**:
|
||||
1. Trip name
|
||||
2. Start date/time
|
||||
3. End date/time
|
||||
4. Notes (rich text)
|
||||
- **Validation**: Date ranges, required fields
|
||||
|
||||
### 6.2 Trip Visualization
|
||||
**Scenario**: View trip details and route
|
||||
- **Trip View**:
|
||||
1. Interactive map with trip route
|
||||
2. Trip statistics (distance, duration)
|
||||
3. Countries visited during trip
|
||||
4. Photo integration (if configured)
|
||||
- **Photo Display**: Grid layout with links to photo sources
|
||||
|
||||
### 6.3 Trip Management
|
||||
**Scenario**: Edit and manage existing trips
|
||||
- **Trip List**: Paginated view of all trips
|
||||
- **Trip Actions**:
|
||||
1. Edit trip details
|
||||
2. Delete trips
|
||||
3. View trip details
|
||||
- **Background Processing**: Distance and route calculations
|
||||
|
||||
---
|
||||
|
||||
## 7. Visits & Places (Beta Feature)
|
||||
|
||||
### 7.1 Visit Suggestions
|
||||
**Scenario**: Automatic visit detection and suggestions
|
||||
- **Process**: Background job analyzes location data
|
||||
- **Detection**: Identifies places where user spent time
|
||||
- **Suggestions**: Creates suggested visits for review
|
||||
- **Notifications**: User receives visit suggestion notifications
|
||||
|
||||
### 7.2 Visit Management
|
||||
**Scenario**: Review and manage visit suggestions
|
||||
- **Entry Point**: Navigation → My data → Visits & Places
|
||||
- **Visit States**:
|
||||
1. Suggested (pending review)
|
||||
2. Confirmed (accepted)
|
||||
3. Declined (rejected)
|
||||
- **Actions**: Confirm, decline, or edit visits
|
||||
- **Filtering**: View by status, order by date
|
||||
|
||||
### 7.3 Places Management
|
||||
**Scenario**: Manage detected places
|
||||
- **Place List**: All places created by visit suggestions
|
||||
- **Place Details**: Name, coordinates, creation date
|
||||
- **Actions**: Delete places (deletes associated visits)
|
||||
- **Integration**: Places linked to visits
|
||||
|
||||
### 7.4 Areas Creation
|
||||
**Scenario**: Create custom areas for visit detection
|
||||
- **Map Interface**: Draw areas on map
|
||||
- **Area Properties**:
|
||||
1. Name
|
||||
2. Radius
|
||||
3. Coordinates (center point)
|
||||
- **Purpose**: Improve visit detection accuracy
|
||||
|
||||
---
|
||||
|
||||
## 8. Points Management
|
||||
|
||||
### 8.1 Points List
|
||||
**Scenario**: View and manage individual location points
|
||||
- **Entry Point**: Navigation → My data → Points
|
||||
- **Display**: Paginated table with point details
|
||||
- **Point Information**:
|
||||
1. Timestamp
|
||||
2. Coordinates
|
||||
3. Accuracy
|
||||
4. Source import
|
||||
- **Filtering**: Date range, import source
|
||||
|
||||
### 8.2 Point Actions
|
||||
**Scenario**: Individual point management
|
||||
- **Point Details**: Click point for popup with full details
|
||||
- **Actions**:
|
||||
1. Delete individual points
|
||||
2. Bulk delete points
|
||||
3. View point source
|
||||
- **Map Integration**: Points clickable on map
|
||||
|
||||
---
|
||||
|
||||
## 9. Notifications System
|
||||
|
||||
### 9.1 Notification Types
|
||||
**Scenario**: System notifications for various events
|
||||
- **Import Notifications**:
|
||||
1. Import completed
|
||||
2. Import failed
|
||||
3. Import progress updates
|
||||
- **Export Notifications**:
|
||||
1. Export completed
|
||||
2. Export failed
|
||||
- **System Notifications**:
|
||||
1. Visit suggestions available
|
||||
2. Statistics updates completed
|
||||
3. Background job failures
|
||||
|
||||
### 9.2 Notification Management
|
||||
**Scenario**: View and manage notifications
|
||||
- **Entry Point**: Bell icon in navigation
|
||||
- **Notification List**: All notifications with timestamps
|
||||
- **Actions**:
|
||||
1. Mark as read
|
||||
2. Mark all as read
|
||||
3. Delete notifications
|
||||
4. Delete all notifications
|
||||
- **Display**: Badges for unread count
|
||||
|
||||
---
|
||||
|
||||
## 10. Settings & Configuration
|
||||
|
||||
### 10.1 Integration Settings
|
||||
**Scenario**: Configure external service integrations
|
||||
- **Entry Point**: Navigation → Settings → Integrations
|
||||
- **Immich Integration**:
|
||||
1. Configure Immich URL
|
||||
2. Set API key
|
||||
3. Test connection
|
||||
- **Photoprism Integration**:
|
||||
1. Configure Photoprism URL
|
||||
2. Set API key
|
||||
3. Test connection
|
||||
|
||||
### 10.2 Map Settings
|
||||
**Scenario**: Configure map appearance and behavior
|
||||
- **Entry Point**: Settings → Map
|
||||
- **Options**:
|
||||
1. Custom tile layer URL
|
||||
2. Map layer name
|
||||
3. Distance unit (km/miles)
|
||||
4. Tile usage statistics
|
||||
- **Preview**: Real-time map preview
|
||||
|
||||
### 10.3 User Settings
|
||||
**Scenario**: Personal preferences and account settings
|
||||
- **Theme**: Light/dark mode toggle
|
||||
- **API Key**: View and regenerate API key
|
||||
- **Visits Settings**: Enable/disable visit suggestions
|
||||
- **Route Settings**: Default route appearance
|
||||
|
||||
---
|
||||
|
||||
## 11. Admin Features (Self-Hosted Only)
|
||||
|
||||
### 11.1 User Management
|
||||
**Scenario**: Admin user management in self-hosted mode
|
||||
- **Entry Point**: Settings → Users (admin only)
|
||||
- **User Actions**:
|
||||
1. Create new users
|
||||
2. Edit user details
|
||||
3. Delete users
|
||||
4. View user statistics
|
||||
- **User Creation**: Email and password setup
|
||||
|
||||
### 11.2 Background Jobs Management
|
||||
**Scenario**: Admin control over background processing
|
||||
- **Entry Point**: Settings → Background Jobs
|
||||
- **Job Types**:
|
||||
1. Reverse geocoding jobs
|
||||
2. Statistics calculation
|
||||
3. Visit suggestion jobs
|
||||
- **Actions**: Start/stop background jobs, view job status
|
||||
|
||||
### 11.3 System Administration
|
||||
**Scenario**: System-level administration
|
||||
- **Sidekiq Dashboard**: Background job monitoring
|
||||
- **System Settings**: Global configuration options
|
||||
- **User Data Management**: Export/import user data
|
||||
|
||||
---
|
||||
|
||||
## 12. API Functionality
|
||||
|
||||
### 12.1 Location Data API
|
||||
**Scenario**: Programmatic location data submission
|
||||
- **Endpoints**: RESTful API for location data
|
||||
- **Authentication**: API key based
|
||||
- **Supported Apps**:
|
||||
1. Dawarich iOS app
|
||||
2. Overland
|
||||
3. OwnTracks
|
||||
4. GPSLogger
|
||||
5. Custom applications
|
||||
|
||||
### 12.2 Data Retrieval API
|
||||
**Scenario**: Retrieve location data via API
|
||||
- **Use Cases**: Third-party integrations, mobile apps
|
||||
- **Data Formats**: JSON, GeoJSON
|
||||
- **Authentication**: API key required
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling & Edge Cases
|
||||
|
||||
### 13.1 Import Errors
|
||||
**Scenario**: Handle various import failure scenarios
|
||||
- **File Format Errors**: Unsupported or corrupted files
|
||||
- **Processing Errors**: Background job failures
|
||||
- **Network Errors**: Failed downloads or API calls
|
||||
- **User Feedback**: Error notifications with details
|
||||
|
||||
### 13.2 System Errors
|
||||
**Scenario**: Handle system-level errors
|
||||
- **Database Errors**: Connection issues, constraints
|
||||
- **Storage Errors**: File system issues
|
||||
- **Memory Errors**: Large data processing
|
||||
- **User Experience**: Graceful error messages
|
||||
|
||||
### 13.3 Data Validation
|
||||
**Scenario**: Validate user input and data integrity
|
||||
- **Coordinate Validation**: Valid latitude/longitude
|
||||
- **Time Validation**: Logical timestamp values
|
||||
- **File Validation**: Supported formats and sizes
|
||||
- **User Input**: Form validation and sanitization
|
||||
|
||||
---
|
||||
|
||||
## 14. Performance & Scalability
|
||||
|
||||
### 14.1 Large Dataset Handling
|
||||
**Scenario**: Handle users with large amounts of location data
|
||||
- **Map Performance**: Efficient rendering of many points
|
||||
- **Data Processing**: Batch processing for imports
|
||||
- **Memory Management**: Streaming for large files
|
||||
- **User Experience**: Progress indicators, pagination
|
||||
|
||||
### 14.2 Background Processing
|
||||
**Scenario**: Asynchronous task handling
|
||||
- **Job Queues**: Sidekiq for background jobs
|
||||
- **Progress Tracking**: Real-time job status
|
||||
- **Error Recovery**: Retry mechanisms
|
||||
- **User Feedback**: Job completion notifications
|
||||
|
||||
---
|
||||
|
||||
## 15. Mobile & Responsive Design
|
||||
|
||||
### 15.1 Mobile Interface
|
||||
**Scenario**: Mobile-optimized user experience
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
- **Touch Interactions**: Map gestures, mobile-friendly controls
|
||||
- **Mobile Navigation**: Collapsible menus
|
||||
- **Performance**: Optimized for mobile devices
|
||||
|
||||
### 15.2 Cross-Platform Compatibility
|
||||
**Scenario**: Consistent experience across devices
|
||||
- **Browser Support**: Modern browser compatibility
|
||||
- **Device Support**: Desktop, tablet, mobile
|
||||
- **Feature Parity**: Full functionality across platforms
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios Priority
|
||||
|
||||
### High Priority (Core Functionality)
|
||||
1. User authentication (sign in/out)
|
||||
2. Map visualization with basic controls
|
||||
3. Data import (at least one source type)
|
||||
4. Basic settings configuration
|
||||
5. Point display and interaction
|
||||
|
||||
### Medium Priority (Extended Features)
|
||||
1. Trip management
|
||||
2. Visit suggestions and management
|
||||
3. Data export
|
||||
4. Statistics viewing
|
||||
5. Notification system
|
||||
|
||||
### Low Priority (Advanced Features)
|
||||
1. Admin functions
|
||||
2. API functionality
|
||||
3. Complex map settings
|
||||
4. Background job management
|
||||
5. Error handling edge cases
|
||||
|
||||
---
|
||||
|
||||
## Notes for Test Implementation
|
||||
|
||||
1. **Test Data**: Use factory-generated test data for consistency
|
||||
2. **API Testing**: Include both UI and API endpoint testing
|
||||
3. **Background Jobs**: Test asynchronous processing
|
||||
4. **File Handling**: Test various file formats and sizes
|
||||
5. **Responsive Testing**: Include mobile viewport testing
|
||||
6. **Performance Testing**: Test with large datasets
|
||||
7. **Error Scenarios**: Include negative test cases
|
||||
8. **Browser Compatibility**: Test across different browsers
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RecalculateStatsAfterChangingDistanceUnits < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
BulkStatsCalculatingJob.perform_later
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20250709195003)
|
||||
DataMigrate::Data.define(version: 20250720171241)
|
||||
|
|
|
|||
296
e2e/README.md
Normal file
296
e2e/README.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# Dawarich E2E Test Suite
|
||||
|
||||
This directory contains comprehensive end-to-end tests for the Dawarich location tracking application using Playwright.
|
||||
|
||||
## Test Structure
|
||||
|
||||
The test suite is organized into several test files that cover different aspects of the application:
|
||||
|
||||
### Core Test Files
|
||||
|
||||
- **`auth.spec.ts`** - Authentication and user management tests
|
||||
- **`map.spec.ts`** - Map functionality and visualization tests
|
||||
- **`imports.spec.ts`** - Data import functionality tests
|
||||
- **`settings.spec.ts`** - Application settings and configuration tests
|
||||
- **`navigation.spec.ts`** - Navigation and UI interaction tests
|
||||
- **`trips.spec.ts`** - Trip management and analysis tests
|
||||
|
||||
### Helper Files
|
||||
|
||||
- **`fixtures/test-helpers.ts`** - Reusable test utilities and helper functions
|
||||
- **`global-setup.ts`** - Global test environment setup
|
||||
- **`example.spec.ts`** - Basic example test (can be removed)
|
||||
|
||||
## Configuration
|
||||
|
||||
- **`playwright.config.ts`** - Playwright configuration with browser setup, timeouts, and test settings
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Node.js and npm installed
|
||||
2. Dawarich application running locally on port 3000 (or configured port)
|
||||
3. Test environment properly configured
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install Playwright
|
||||
npm install -D @playwright/test
|
||||
|
||||
# Install browsers (first time only)
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run tests in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test auth.spec.ts
|
||||
|
||||
# Run tests with specific browser
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Run tests in debug mode
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Test Reports
|
||||
|
||||
```bash
|
||||
# Generate HTML report
|
||||
npx playwright show-report
|
||||
|
||||
# View last test results
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### High Priority Features (✅ Covered)
|
||||
- User authentication (login/logout)
|
||||
- Map visualization and interaction
|
||||
- Data import from various sources
|
||||
- Basic settings configuration
|
||||
- Navigation and UI interactions
|
||||
- Trip management and creation
|
||||
|
||||
### Medium Priority Features (✅ Covered)
|
||||
- Settings management (integrations, map config)
|
||||
- Mobile responsive behavior
|
||||
- Data visualization and statistics
|
||||
- File upload handling
|
||||
- User preferences and customization
|
||||
|
||||
### Low Priority Features (✅ Covered)
|
||||
- Advanced trip analysis
|
||||
- Performance testing
|
||||
- Error handling
|
||||
- Accessibility testing
|
||||
- Keyboard navigation
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Helper Functions
|
||||
|
||||
Use the `TestHelpers` class for common operations:
|
||||
|
||||
```typescript
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test('example', async ({ page }) => {
|
||||
const helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
Tests are organized with descriptive `test.describe` blocks:
|
||||
|
||||
```typescript
|
||||
test.describe('Feature Name', () => {
|
||||
test.describe('Sub-feature', () => {
|
||||
test('should do something specific', async ({ page }) => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
Use clear, descriptive assertions:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await expect(page.getByRole('heading', { name: 'Map' })).toBeVisible();
|
||||
|
||||
// Better with context
|
||||
await expect(page.getByRole('button', { name: 'Create Trip' })).toBeVisible();
|
||||
```
|
||||
|
||||
## Configuration Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The tests use these environment variables:
|
||||
|
||||
- `BASE_URL` - Base URL for the application (defaults to http://localhost:3000)
|
||||
- `CI` - Set to true in CI environments
|
||||
|
||||
### Test Data
|
||||
|
||||
Tests use the demo user credentials:
|
||||
- Email: `demo@dawarich.app`
|
||||
- Password: `password`
|
||||
|
||||
### Browser Configuration
|
||||
|
||||
Tests run on:
|
||||
- Chromium (primary)
|
||||
- Firefox
|
||||
- WebKit (Safari)
|
||||
- Mobile Chrome
|
||||
- Mobile Safari
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Independence
|
||||
|
||||
Each test should be independent and able to run in isolation:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Robust Selectors
|
||||
|
||||
Use semantic selectors that won't break easily:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await page.getByRole('button', { name: 'Save' });
|
||||
await page.getByLabel('Email');
|
||||
|
||||
// Avoid
|
||||
await page.locator('.btn-primary');
|
||||
await page.locator('#email-input');
|
||||
```
|
||||
|
||||
### 3. Wait for Conditions
|
||||
|
||||
Wait for specific conditions rather than arbitrary timeouts:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('Success')).toBeVisible();
|
||||
|
||||
// Avoid
|
||||
await page.waitForTimeout(5000);
|
||||
```
|
||||
|
||||
### 4. Handle Optional Elements
|
||||
|
||||
Use conditional logic for elements that may not exist:
|
||||
|
||||
```typescript
|
||||
const deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Mobile Testing
|
||||
|
||||
Include mobile viewport testing:
|
||||
|
||||
```typescript
|
||||
test('should work on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. Create tests in the appropriate spec file
|
||||
2. Use descriptive test names
|
||||
3. Follow the existing patterns
|
||||
4. Update this README if adding new test files
|
||||
|
||||
### Updating Selectors
|
||||
|
||||
When the application UI changes:
|
||||
1. Update selectors in helper functions first
|
||||
2. Run tests to identify breaking changes
|
||||
3. Update individual test files as needed
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Tests include performance checks for critical paths
|
||||
- Map loading times are monitored
|
||||
- Navigation speed is tested
|
||||
- Large dataset handling is verified
|
||||
|
||||
## Debugging
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Server not ready** - Ensure Dawarich is running on the correct port
|
||||
2. **Element not found** - Check if UI has changed or element is conditionally rendered
|
||||
3. **Timeouts** - Verify network conditions and increase timeouts if needed
|
||||
4. **Map not loading** - Ensure map dependencies are available
|
||||
|
||||
### Debug Tips
|
||||
|
||||
```bash
|
||||
# Run with debug flag
|
||||
npx playwright test --debug
|
||||
|
||||
# Run specific test with trace
|
||||
npx playwright test auth.spec.ts --trace on
|
||||
|
||||
# Record video on failure
|
||||
npx playwright test --video retain-on-failure
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The test suite is configured for CI/CD with:
|
||||
- Automatic retry on failure
|
||||
- Parallel execution control
|
||||
- Artifact collection (screenshots, videos, traces)
|
||||
- HTML report generation
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
1. Follow the existing patterns
|
||||
2. Add appropriate test coverage
|
||||
3. Update documentation
|
||||
4. Ensure tests pass in all browsers
|
||||
5. Consider mobile and accessibility aspects
|
||||
|
||||
## Support
|
||||
|
||||
For issues with the test suite:
|
||||
1. Check the test logs and reports
|
||||
2. Verify application state
|
||||
3. Review recent changes
|
||||
4. Check browser compatibility
|
||||
509
e2e/auth.spec.ts
Normal file
509
e2e/auth.spec.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers, TEST_USERS } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
});
|
||||
|
||||
test.describe('Login and Logout', () => {
|
||||
test('should display login page correctly', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check page elements based on actual Devise view
|
||||
await expect(page).toHaveTitle(/Dawarich/);
|
||||
await expect(page.getByRole('heading', { name: 'Login now' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Forgot your password?' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show demo credentials in demo environment', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check if demo credentials are shown (they may not be in test environment)
|
||||
const demoCredentials = page.getByText('demo@dawarich.app');
|
||||
if (await demoCredentials.isVisible()) {
|
||||
await expect(demoCredentials).toBeVisible();
|
||||
await expect(page.getByText('password').nth(1)).toBeVisible(); // Second "password" text
|
||||
}
|
||||
});
|
||||
|
||||
test('should login with valid credentials', async ({ page }) => {
|
||||
await helpers.loginAsDemo();
|
||||
|
||||
// Verify successful login - should redirect to map
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reject invalid credentials', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
await page.getByLabel('Email').fill('invalid@email.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
// Should stay on login page and show error
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
// Devise shows error messages - look for error indication
|
||||
const errorMessage = page.locator('#error_explanation, .alert, .flash').filter({ hasText: /invalid/i });
|
||||
if (await errorMessage.isVisible()) {
|
||||
await expect(errorMessage).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should remember user when "Remember me" is checked', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Look for remember me checkbox - use getByRole to target the actual checkbox
|
||||
const rememberCheckbox = page.getByRole('checkbox', { name: 'Remember me' });
|
||||
|
||||
if (await rememberCheckbox.isVisible()) {
|
||||
await rememberCheckbox.check();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
// Wait for redirect with longer timeout
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Check for remember token cookie
|
||||
const cookies = await page.context().cookies();
|
||||
const hasPersistentCookie = cookies.some(cookie =>
|
||||
cookie.name.includes('remember') || cookie.name.includes('session')
|
||||
);
|
||||
expect(hasPersistentCookie).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
await helpers.loginAsDemo();
|
||||
|
||||
// Open user dropdown using the actual navigation structure
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Use evaluate to trigger the logout form submission properly
|
||||
await page.evaluate(() => {
|
||||
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||
if (logoutLink) {
|
||||
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||
const form = document.createElement('form');
|
||||
form.action = '/users/sign_out';
|
||||
form.method = 'post';
|
||||
form.style.display = 'none';
|
||||
|
||||
// Add method override for DELETE
|
||||
const methodInput = document.createElement('input');
|
||||
methodInput.type = 'hidden';
|
||||
methodInput.name = '_method';
|
||||
methodInput.value = 'delete';
|
||||
form.appendChild(methodInput);
|
||||
|
||||
// Add CSRF token
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'authenticity_token';
|
||||
const tokenValue = csrfToken.getAttribute('content');
|
||||
if (tokenValue) {
|
||||
csrfInput.value = tokenValue;
|
||||
}
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for redirect and navigate to home to verify logout
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// Verify user is logged out - should see login options
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing protected pages while logged out', async ({ page }) => {
|
||||
await page.goto('/map');
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: Update TEST_USERS in fixtures/test-helpers.ts with correct credentials
|
||||
// that match your localhost:3000 server setup
|
||||
test.describe('Password Management', () => {
|
||||
test('should display forgot password form', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/password\/new/);
|
||||
await expect(page.getByRole('heading', { name: 'Forgot your password?' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle password reset request', async ({ page }) => {
|
||||
await page.goto('/users/password/new');
|
||||
|
||||
// Fill the email but don't submit to avoid sending actual reset emails
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
|
||||
// Verify the form elements exist and are functional
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toHaveValue(TEST_USERS.DEMO.email);
|
||||
|
||||
// Test form validation by clearing email and checking if button is still clickable
|
||||
await page.getByLabel('Email').fill('');
|
||||
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should change password when logged in', async ({ page }) => {
|
||||
// Manual login for this test
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Navigate to account settings through user dropdown
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
|
||||
// Check password change form is available - be more specific with selectors
|
||||
await expect(page.locator('input[id="user_password"]')).toBeVisible();
|
||||
await expect(page.getByLabel('Current password')).toBeVisible();
|
||||
|
||||
// Test filling the form but don't submit to avoid changing the password
|
||||
await page.locator('input[id="user_password"]').fill('newpassword123');
|
||||
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Verify the form can be filled and update button is present
|
||||
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||
|
||||
// Clear the password fields to avoid changing credentials
|
||||
await page.locator('input[id="user_password"]').fill('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Account Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Fresh login for each test in this describe block
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should display account settings page', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
await expect(page.getByRole('heading', { name: 'Edit your account!' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should update email address with current password', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// Test that we can fill the form, but don't actually submit to avoid changing credentials
|
||||
await page.getByLabel('Email').fill('newemail@test.com');
|
||||
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||
|
||||
// Verify the form elements are present and fillable, but don't submit
|
||||
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||
|
||||
// Reset the email field to avoid confusion
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
});
|
||||
|
||||
test('should view API key in settings', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// API key should be visible in the account section
|
||||
await expect(page.getByText('Use this API key')).toBeVisible();
|
||||
await expect(page.locator('code').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should generate new API key', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
// Get current API key
|
||||
const currentApiKey = await page.locator('code').first().textContent();
|
||||
|
||||
// Verify the generate new API key link exists but don't click it to avoid changing the key
|
||||
const generateKeyLink = page.getByRole('link', { name: 'Generate new API key' });
|
||||
await expect(generateKeyLink).toBeVisible();
|
||||
|
||||
// Verify the API key is displayed
|
||||
await expect(page.locator('code').first()).toBeVisible();
|
||||
expect(currentApiKey).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should change theme', async ({ page }) => {
|
||||
// Theme toggle is in the navbar
|
||||
const themeButton = page.locator('svg').locator('..').filter({ hasText: /path/ });
|
||||
|
||||
if (await themeButton.isVisible()) {
|
||||
// Get current theme
|
||||
const htmlElement = page.locator('html');
|
||||
const currentTheme = await htmlElement.getAttribute('data-theme');
|
||||
|
||||
await themeButton.click();
|
||||
|
||||
// Wait for theme change
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Theme should have changed
|
||||
const newTheme = await htmlElement.getAttribute('data-theme');
|
||||
expect(newTheme).not.toBe(currentTheme);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Registration (Non-Self-Hosted)', () => {
|
||||
test('should show registration link when not self-hosted', async ({ page }) => {
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Registration link may or may not be visible depending on SELF_HOSTED setting
|
||||
const registerLink = page.getByRole('link', { name: 'Register' }).first(); // Use first to avoid strict mode
|
||||
const selfHosted = await page.getAttribute('html', 'data-self-hosted');
|
||||
|
||||
if (selfHosted === 'false') {
|
||||
await expect(registerLink).toBeVisible();
|
||||
} else {
|
||||
await expect(registerLink).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display registration form when available', async ({ page }) => {
|
||||
await page.goto('/users/sign_up');
|
||||
|
||||
// May redirect if self-hosted, so check current URL
|
||||
if (page.url().includes('/users/sign_up')) {
|
||||
await expect(page.getByRole('heading', { name: 'Register now!' })).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.locator('input[id="user_password"]')).toBeVisible(); // Be specific for main password field
|
||||
await expect(page.locator('input[id="user_password_confirmation"]')).toBeVisible(); // Use ID for confirmation field
|
||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Authentication', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await page.goto('/users/sign_in');
|
||||
|
||||
// Check mobile-responsive login form
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
|
||||
// Test login on mobile
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should handle mobile navigation after login', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Open mobile navigation using hamburger menu
|
||||
const mobileMenuButton = page.locator('label[tabindex="0"]').or(
|
||||
page.locator('button').filter({ hasText: /menu/i })
|
||||
);
|
||||
|
||||
if (await mobileMenuButton.isVisible()) {
|
||||
await mobileMenuButton.click();
|
||||
|
||||
// Should see user email in mobile menu structure
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile logout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// In mobile view, user dropdown should still work
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Use evaluate to trigger the logout form submission properly
|
||||
await page.evaluate(() => {
|
||||
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||
if (logoutLink) {
|
||||
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||
const form = document.createElement('form');
|
||||
form.action = '/users/sign_out';
|
||||
form.method = 'post';
|
||||
form.style.display = 'none';
|
||||
|
||||
// Add method override for DELETE
|
||||
const methodInput = document.createElement('input');
|
||||
methodInput.type = 'hidden';
|
||||
methodInput.name = '_method';
|
||||
methodInput.value = 'delete';
|
||||
form.appendChild(methodInput);
|
||||
|
||||
// Add CSRF token
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'authenticity_token';
|
||||
const tokenValue = csrfToken.getAttribute('content');
|
||||
if (tokenValue) {
|
||||
csrfInput.value = tokenValue;
|
||||
}
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for redirect and navigate to home to verify logout
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// Verify user is logged out - should see login options
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation Integration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Manual login for each test in this describe block
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should show user email in navigation', async ({ page }) => {
|
||||
// User email should be visible in the navbar dropdown
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show admin indicator for admin users', async ({ page }) => {
|
||||
// Look for admin star indicator if user is admin
|
||||
const adminStar = page.getByText('⭐️');
|
||||
// Admin indicator may not be visible for demo user
|
||||
const isVisible = await adminStar.isVisible();
|
||||
// Just verify the page doesn't crash
|
||||
expect(typeof isVisible).toBe('boolean');
|
||||
});
|
||||
|
||||
test('should access settings through navigation', async ({ page }) => {
|
||||
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show version badge in navigation', async ({ page }) => {
|
||||
// Version badge should be visible
|
||||
const versionBadge = page.locator('.badge').filter({ hasText: /\d+\.\d+/ });
|
||||
await expect(versionBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show notifications dropdown', async ({ page }) => {
|
||||
// Notifications dropdown should be present - look for the notification bell icon more directly
|
||||
const notificationDropdown = page.locator('[data-controller="notifications"]');
|
||||
|
||||
if (await notificationDropdown.isVisible()) {
|
||||
await expect(notificationDropdown).toBeVisible();
|
||||
} else {
|
||||
// Alternative: Look for notification button/bell icon
|
||||
const notificationButton = page.locator('svg').filter({ hasText: /path.*stroke.*d=/ });
|
||||
if (await notificationButton.first().isVisible()) {
|
||||
await expect(notificationButton.first()).toBeVisible();
|
||||
} else {
|
||||
// If notifications aren't available, just check that the navbar exists
|
||||
const navbar = page.locator('.navbar');
|
||||
await expect(navbar).toBeVisible();
|
||||
console.log('Notifications dropdown not found, but navbar is present');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session Management', () => {
|
||||
test('should maintain session across page reloads', async ({ page }) => {
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should still be logged in
|
||||
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
});
|
||||
|
||||
test('should handle session timeout gracefully', async ({ page }) => {
|
||||
// Manual login
|
||||
await page.goto('/users/sign_in');
|
||||
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Clear all cookies to simulate session timeout
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Try to access protected page
|
||||
await page.goto('/settings');
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
e2e/fixtures/test-helpers.ts
Normal file
366
e2e/fixtures/test-helpers.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export interface TestUser {
|
||||
email: string;
|
||||
password: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export class TestHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Navigate to the home page
|
||||
*/
|
||||
async goToHomePage() {
|
||||
await this.page.goto('/');
|
||||
await expect(this.page).toHaveTitle(/Dawarich/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with provided credentials
|
||||
*/
|
||||
async login(user: TestUser) {
|
||||
await this.page.goto('/users/sign_in');
|
||||
|
||||
// Fill in login form using actual Devise structure
|
||||
await this.page.getByLabel('Email').fill(user.email);
|
||||
await this.page.getByLabel('Password').fill(user.password);
|
||||
|
||||
// Submit login
|
||||
await this.page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
// Wait for navigation to complete - use the same approach as working tests
|
||||
await this.page.waitForURL(/\/map/, { timeout: 10000 });
|
||||
|
||||
// Verify user is logged in by checking for email in navbar
|
||||
await expect(this.page.getByText(user.email)).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with demo credentials
|
||||
*/
|
||||
async loginAsDemo() {
|
||||
await this.login({ email: 'demo@dawarich.app', password: 'password' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user using actual navigation structure
|
||||
*/
|
||||
async logout() {
|
||||
// Open user dropdown using the actual navigation structure - use first() to avoid strict mode
|
||||
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Use evaluate to trigger the logout form submission properly
|
||||
await this.page.evaluate(() => {
|
||||
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||
if (logoutLink) {
|
||||
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||
const form = document.createElement('form');
|
||||
form.action = '/users/sign_out';
|
||||
form.method = 'post';
|
||||
form.style.display = 'none';
|
||||
|
||||
// Add method override for DELETE
|
||||
const methodInput = document.createElement('input');
|
||||
methodInput.type = 'hidden';
|
||||
methodInput.name = '_method';
|
||||
methodInput.value = 'delete';
|
||||
form.appendChild(methodInput);
|
||||
|
||||
// Add CSRF token
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'authenticity_token';
|
||||
const tokenValue = csrfToken.getAttribute('content');
|
||||
if (tokenValue) {
|
||||
csrfInput.value = tokenValue;
|
||||
}
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for redirect and navigate to home to verify logout
|
||||
await this.page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// Verify user is logged out - should see login options
|
||||
await expect(this.page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific section using actual navigation structure
|
||||
*/
|
||||
async navigateTo(section: 'Map' | 'Trips' | 'Stats' | 'Points' | 'Visits' | 'Imports' | 'Exports' | 'Settings') {
|
||||
// Check if already on the target page
|
||||
const currentUrl = this.page.url();
|
||||
const targetPath = section.toLowerCase();
|
||||
|
||||
if (section === 'Map' && (currentUrl.includes('/map') || currentUrl.endsWith('/'))) {
|
||||
// Already on map page, just navigate directly
|
||||
await this.page.goto('/map');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle nested menu items that are in "My data" dropdown
|
||||
if (['Points', 'Visits', 'Imports', 'Exports'].includes(section)) {
|
||||
// Open "My data" dropdown - select the visible one (not the hidden mobile version)
|
||||
const myDataDropdown = this.page.locator('details').filter({ hasText: 'My data' }).and(this.page.locator(':visible'));
|
||||
await myDataDropdown.locator('summary').click();
|
||||
|
||||
// Handle special cases for visit links
|
||||
if (section === 'Visits') {
|
||||
await this.page.getByRole('link', { name: 'Visits & Places' }).click();
|
||||
} else {
|
||||
await this.page.getByRole('link', { name: section }).click();
|
||||
}
|
||||
} else if (section === 'Settings') {
|
||||
// Settings is accessed through user dropdown - use first() to avoid strict mode
|
||||
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await this.page.getByRole('link', { name: 'Settings' }).click();
|
||||
} else {
|
||||
// Direct navigation items (Map, Trips, Stats)
|
||||
// Try to find the link, if not found, navigate directly
|
||||
const navLink = this.page.getByRole('link', { name: section });
|
||||
try {
|
||||
await navLink.click({ timeout: 2000 });
|
||||
} catch (error) {
|
||||
// If link not found, navigate directly to the page
|
||||
await this.page.goto(`/${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for page to load
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for map to be loaded and interactive
|
||||
*/
|
||||
async waitForMap() {
|
||||
// Wait for map container to be visible - the #map element is always present
|
||||
await expect(this.page.locator('#map')).toBeVisible();
|
||||
|
||||
// Wait for map controls to be available (indicates map is functional)
|
||||
await expect(this.page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
|
||||
// Wait a bit more for any async loading
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification with specific text is visible
|
||||
*/
|
||||
async expectNotification(text: string, type: 'success' | 'error' | 'info' = 'success') {
|
||||
// Use actual flash message structure from Dawarich
|
||||
const notification = this.page.locator('#flash-messages .alert, #flash-messages div').filter({ hasText: text });
|
||||
await expect(notification.first()).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file using the file input
|
||||
*/
|
||||
async uploadFile(inputSelector: string, filePath: string) {
|
||||
const fileInput = this.page.locator(inputSelector);
|
||||
await fileInput.setInputFiles(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for background job to complete (polling approach)
|
||||
*/
|
||||
async waitForJobCompletion(jobName: string, timeout = 30000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
// Check if there's a completion notification in flash messages
|
||||
const completionNotification = this.page.locator('#flash-messages').filter({
|
||||
hasText: new RegExp(jobName + '.*(completed|finished|done)', 'i')
|
||||
});
|
||||
|
||||
if (await completionNotification.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Job "${jobName}" did not complete within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test file content for imports
|
||||
*/
|
||||
createTestGeoJSON(pointCount = 10): string {
|
||||
const features: any[] = [];
|
||||
const baseTime = Date.now() - (pointCount * 60 * 1000); // Points every minute
|
||||
|
||||
for (let i = 0; i < pointCount; i++) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-74.0060 + (i * 0.001), 40.7128 + (i * 0.001)]
|
||||
},
|
||||
properties: {
|
||||
timestamp: Math.floor((baseTime + (i * 60 * 1000)) / 1000)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is visible on mobile viewports
|
||||
*/
|
||||
async isMobileViewport(): Promise<boolean> {
|
||||
const viewport = this.page.viewportSize();
|
||||
return viewport ? viewport.width < 768 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile navigation (hamburger menu) using actual structure
|
||||
*/
|
||||
async openMobileNavigation() {
|
||||
if (await this.isMobileViewport()) {
|
||||
// Use actual mobile menu button structure from navbar
|
||||
const mobileMenuButton = this.page.locator('label[tabindex="0"]').or(
|
||||
this.page.locator('button').filter({ hasText: /menu/i })
|
||||
);
|
||||
|
||||
if (await mobileMenuButton.isVisible()) {
|
||||
await mobileMenuButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access account settings through user dropdown
|
||||
*/
|
||||
async goToAccountSettings() {
|
||||
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await this.page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(this.page).toHaveURL(/\/users\/edit/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin by looking for admin indicator
|
||||
*/
|
||||
async isUserAdmin(): Promise<boolean> {
|
||||
const adminStar = this.page.getByText('⭐️');
|
||||
return await adminStar.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme from HTML data attribute
|
||||
*/
|
||||
async getCurrentTheme(): Promise<string | null> {
|
||||
return await this.page.getAttribute('html', 'data-theme');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app is in self-hosted mode
|
||||
*/
|
||||
async isSelfHosted(): Promise<boolean> {
|
||||
const selfHosted = await this.page.getAttribute('html', 'data-self-hosted');
|
||||
return selfHosted === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle theme using navbar theme button
|
||||
*/
|
||||
async toggleTheme() {
|
||||
// Theme button is an SVG inside a link
|
||||
const themeButton = this.page.locator('svg').locator('..').filter({ hasText: /path/ });
|
||||
|
||||
if (await themeButton.isVisible()) {
|
||||
await themeButton.click();
|
||||
// Wait for theme change to take effect
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications dropdown is available
|
||||
*/
|
||||
async hasNotifications(): Promise<boolean> {
|
||||
const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ });
|
||||
return await notificationButton.first().isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open notifications dropdown
|
||||
*/
|
||||
async openNotifications() {
|
||||
if (await this.hasNotifications()) {
|
||||
const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ }).first();
|
||||
await notificationButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new API key from account settings
|
||||
*/
|
||||
async generateNewApiKey() {
|
||||
await this.goToAccountSettings();
|
||||
|
||||
// Get current API key
|
||||
const currentApiKey = await this.page.locator('code').first().textContent();
|
||||
|
||||
// Click generate new API key button
|
||||
await this.page.getByRole('link', { name: 'Generate new API key' }).click();
|
||||
|
||||
// Wait for page to reload with new key
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
// Return new API key
|
||||
const newApiKey = await this.page.locator('code').first().textContent();
|
||||
return { currentApiKey, newApiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Access specific settings section
|
||||
*/
|
||||
async goToSettings(section?: 'Maps' | 'Background Jobs' | 'Users') {
|
||||
await this.navigateTo('Settings');
|
||||
|
||||
if (section) {
|
||||
// Click on the specific settings tab
|
||||
await this.page.getByRole('tab', { name: section }).click();
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test data constants
|
||||
export const TEST_USERS = {
|
||||
DEMO: {
|
||||
email: 'demo@dawarich.app',
|
||||
password: 'password'
|
||||
},
|
||||
ADMIN: {
|
||||
email: 'admin@dawarich.app',
|
||||
password: 'password',
|
||||
isAdmin: true
|
||||
}
|
||||
};
|
||||
|
||||
export const TEST_COORDINATES = {
|
||||
NYC: { lat: 40.7128, lon: -74.0060, name: 'New York City' },
|
||||
LONDON: { lat: 51.5074, lon: -0.1278, name: 'London' },
|
||||
TOKYO: { lat: 35.6762, lon: 139.6503, name: 'Tokyo' }
|
||||
};
|
||||
39
e2e/global-setup.ts
Normal file
39
e2e/global-setup.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { chromium, FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
// Launch browser for setup operations
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
// Wait for the server to be ready
|
||||
console.log('Checking if Dawarich server is available...');
|
||||
|
||||
// Try to connect to the health endpoint
|
||||
try {
|
||||
await page.goto(baseURL + '/api/v1/health', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
console.log('Health endpoint is accessible');
|
||||
} catch (error) {
|
||||
console.log('Health endpoint not available, trying main page...');
|
||||
}
|
||||
|
||||
// Check if we can access the main app
|
||||
const response = await page.goto(baseURL + '/', { timeout: 15000 });
|
||||
if (!response?.ok()) {
|
||||
throw new Error(`Server not available. Status: ${response?.status()}. Make sure Dawarich is running on ${baseURL}`);
|
||||
}
|
||||
|
||||
console.log('Dawarich server is ready for testing');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Dawarich server:', error);
|
||||
console.error(`Please make sure Dawarich is running on ${baseURL}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
427
e2e/map.spec.ts
Normal file
427
e2e/map.spec.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Map Functionality', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Main Map Interface', () => {
|
||||
test('should display map page correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check page title and basic elements
|
||||
await expect(page).toHaveTitle(/Map.*Dawarich/);
|
||||
// Check for map controls instead of specific #map element
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
|
||||
// Wait for map to be fully loaded
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Check for time range controls
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Search' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should load Leaflet map correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Check that map functionality is available - either Leaflet or other map implementation
|
||||
const mapInitialized = await page.evaluate(() => {
|
||||
const mapElement = document.querySelector('#map');
|
||||
return mapElement && (mapElement as any)._leaflet_id;
|
||||
});
|
||||
|
||||
// If Leaflet is not found, check for basic map functionality
|
||||
if (!mapInitialized) {
|
||||
// Verify map controls are working
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||
} else {
|
||||
expect(mapInitialized).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display time range controls', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check time controls
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
|
||||
// Check quick time range buttons
|
||||
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Last 7 days' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Last month' })).toBeVisible();
|
||||
|
||||
// Check navigation arrows
|
||||
await expect(page.getByRole('link', { name: '◀️' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '▶️' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate between dates using arrows', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Wait for initial page load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify navigation arrows exist and are functional
|
||||
const prevArrow = page.getByRole('link', { name: '◀️' });
|
||||
const nextArrow = page.getByRole('link', { name: '▶️' });
|
||||
|
||||
await expect(prevArrow).toBeVisible();
|
||||
await expect(nextArrow).toBeVisible();
|
||||
|
||||
// Check that arrows have proper href attributes with date parameters
|
||||
const prevHref = await prevArrow.getAttribute('href');
|
||||
const nextHref = await nextArrow.getAttribute('href');
|
||||
|
||||
expect(prevHref).toContain('start_at');
|
||||
expect(nextHref).toContain('start_at');
|
||||
});
|
||||
|
||||
test('should use quick time range buttons', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Verify quick time range buttons exist and have proper hrefs
|
||||
const todayButton = page.getByRole('link', { name: 'Today' });
|
||||
const lastWeekButton = page.getByRole('link', { name: 'Last 7 days' });
|
||||
const lastMonthButton = page.getByRole('link', { name: 'Last month' });
|
||||
|
||||
await expect(todayButton).toBeVisible();
|
||||
await expect(lastWeekButton).toBeVisible();
|
||||
await expect(lastMonthButton).toBeVisible();
|
||||
|
||||
// Check that buttons have proper href attributes with date parameters
|
||||
const todayHref = await todayButton.getAttribute('href');
|
||||
const lastWeekHref = await lastWeekButton.getAttribute('href');
|
||||
const lastMonthHref = await lastMonthButton.getAttribute('href');
|
||||
|
||||
expect(todayHref).toContain('start_at');
|
||||
expect(lastWeekHref).toContain('start_at');
|
||||
expect(lastMonthHref).toContain('start_at');
|
||||
});
|
||||
|
||||
test('should search custom date range', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Verify custom date range form exists
|
||||
const startInput = page.getByLabel('Start at');
|
||||
const endInput = page.getByLabel('End at');
|
||||
const searchButton = page.getByRole('button', { name: 'Search' });
|
||||
|
||||
await expect(startInput).toBeVisible();
|
||||
await expect(endInput).toBeVisible();
|
||||
await expect(searchButton).toBeVisible();
|
||||
|
||||
// Test that we can interact with the form
|
||||
await startInput.fill('2024-01-01T00:00');
|
||||
await endInput.fill('2024-01-02T23:59');
|
||||
|
||||
// Verify form inputs work
|
||||
await expect(startInput).toHaveValue('2024-01-01T00:00');
|
||||
await expect(endInput).toHaveValue('2024-01-02T23:59');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Layers and Controls', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should display layer control', async ({ page }) => {
|
||||
// Look for layer control (Leaflet control)
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await expect(layerControl).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle layer control', async ({ page }) => {
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
|
||||
if (await layerControl.isVisible()) {
|
||||
// Click to expand if collapsed
|
||||
await layerControl.click();
|
||||
|
||||
// Should show layer options
|
||||
await page.waitForTimeout(500);
|
||||
// Layer control should be expanded (check for typical layer control elements)
|
||||
const expanded = await page.locator('.leaflet-control-layers-expanded').isVisible();
|
||||
if (!expanded) {
|
||||
// Try clicking on the control toggle
|
||||
const toggle = layerControl.locator('.leaflet-control-layers-toggle');
|
||||
if (await toggle.isVisible()) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should switch between base layers', async ({ page }) => {
|
||||
// This test depends on having multiple base layers available
|
||||
// We'll check if base layer options exist and try to switch
|
||||
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await layerControl.click();
|
||||
|
||||
// Look for base layer radio buttons (OpenStreetMap, OpenTopo, etc.)
|
||||
const baseLayerRadios = page.locator('input[type="radio"][name="leaflet-base-layers"]');
|
||||
const radioCount = await baseLayerRadios.count();
|
||||
|
||||
if (radioCount > 1) {
|
||||
// Switch to different base layer
|
||||
await baseLayerRadios.nth(1).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the layer switched (tiles should reload)
|
||||
await expect(page.locator('.leaflet-tile-loaded')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should toggle overlay layers', async ({ page }) => {
|
||||
const layerControl = page.locator('.leaflet-control-layers');
|
||||
await layerControl.click();
|
||||
|
||||
// Wait for the layer control to expand
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Look for overlay checkboxes (Points, Routes, Heatmap, etc.)
|
||||
const overlayCheckboxes = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]');
|
||||
const checkboxCount = await overlayCheckboxes.count();
|
||||
|
||||
if (checkboxCount > 0) {
|
||||
// Toggle first overlay - check if it's visible first
|
||||
const firstCheckbox = overlayCheckboxes.first();
|
||||
|
||||
// Wait for checkbox to be visible, especially on mobile
|
||||
await expect(firstCheckbox).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const wasChecked = await firstCheckbox.isChecked();
|
||||
|
||||
// If on mobile, the checkbox might be hidden behind other elements
|
||||
// Use JavaScript click as fallback
|
||||
try {
|
||||
await firstCheckbox.click({ force: true });
|
||||
} catch (error) {
|
||||
// Fallback to JavaScript click if element is not interactable
|
||||
await page.evaluate(() => {
|
||||
const checkbox = document.querySelector('.leaflet-control-layers-overlays input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify state changed
|
||||
const isNowChecked = await firstCheckbox.isChecked();
|
||||
expect(isNowChecked).toBe(!wasChecked);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Data Display', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should display distance and points statistics', async ({ page }) => {
|
||||
// Check for distance and points statistics - they appear as "0 km | 1 points"
|
||||
const statsDisplay = page.getByText(/\d+\s*km.*\d+\s*points/i);
|
||||
await expect(statsDisplay.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display map attribution', async ({ page }) => {
|
||||
// Check for Leaflet attribution
|
||||
const attribution = page.locator('.leaflet-control-attribution');
|
||||
await expect(attribution).toBeVisible();
|
||||
|
||||
// Should contain some attribution text
|
||||
const attributionText = await attribution.textContent();
|
||||
expect(attributionText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should display map scale control', async ({ page }) => {
|
||||
// Check for scale control
|
||||
const scaleControl = page.locator('.leaflet-control-scale');
|
||||
await expect(scaleControl).toBeVisible();
|
||||
});
|
||||
|
||||
test('should zoom in and out', async ({ page }) => {
|
||||
// Find zoom controls
|
||||
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||
const zoomOut = page.locator('.leaflet-control-zoom-out');
|
||||
|
||||
await expect(zoomIn).toBeVisible();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
|
||||
// Test zoom in
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Test zoom out
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be visible and functional
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle map dragging', async ({ page }) => {
|
||||
// Get map container
|
||||
const mapContainer = page.locator('#map .leaflet-container');
|
||||
await expect(mapContainer).toBeVisible();
|
||||
|
||||
// Get initial map center (if available)
|
||||
const initialBounds = await page.evaluate(() => {
|
||||
const mapElement = document.querySelector('#map');
|
||||
if (mapElement && (mapElement as any)._leaflet_id) {
|
||||
const map = (window as any).L.map((mapElement as any)._leaflet_id);
|
||||
return map.getBounds();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Simulate drag
|
||||
await mapContainer.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(100, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Points Interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
});
|
||||
|
||||
test('should click on points to show details', async ({ page }) => {
|
||||
// Look for point markers on the map
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||
const markerCount = await pointMarkers.count();
|
||||
|
||||
if (markerCount > 0) {
|
||||
// Click on first point
|
||||
await pointMarkers.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show popup with point details
|
||||
const popup = page.locator('.leaflet-popup, .popup');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
// Popup should contain some data
|
||||
const popupContent = await popup.textContent();
|
||||
expect(popupContent).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should show point deletion option in popup', async ({ page }) => {
|
||||
// This test assumes there are points to click on
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||
const markerCount = await pointMarkers.count();
|
||||
|
||||
if (markerCount > 0) {
|
||||
await pointMarkers.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Look for delete option in popup
|
||||
const deleteLink = page.getByRole('link', { name: /delete/i });
|
||||
if (await deleteLink.isVisible()) {
|
||||
await expect(deleteLink).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Map Experience', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Map should be visible and functional on mobile
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
|
||||
// Time controls should be responsive
|
||||
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||
await expect(page.getByLabel('End at')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle mobile touch interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
|
||||
// Simulate touch interactions using click (more compatible than tap)
|
||||
await mapContainer.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Map should remain functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display mobile-optimized controls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check that controls stack properly on mobile
|
||||
const timeControls = page.locator('.flex').filter({ hasText: /Start at|End at/ });
|
||||
await expect(timeControls.first()).toBeVisible();
|
||||
|
||||
// Quick action buttons should be visible
|
||||
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Map Performance', () => {
|
||||
test('should load map within reasonable time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.waitForMap();
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// Check if we're on mobile and adjust timeout accordingly
|
||||
const isMobile = await helpers.isMobileViewport();
|
||||
const maxLoadTime = isMobile ? 25000 : 15000; // 25s for mobile, 15s for desktop
|
||||
|
||||
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||
});
|
||||
|
||||
test('should handle large datasets efficiently', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Set a longer date range that might have more data
|
||||
await page.getByLabel('Start at').fill('2024-01-01T00:00');
|
||||
await page.getByLabel('End at').fill('2024-12-31T23:59');
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
|
||||
// Should load without timing out
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Map should still be interactive
|
||||
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
472
e2e/navigation.spec.ts
Normal file
472
e2e/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Main Navigation', () => {
|
||||
test('should display main navigation elements', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check for main navigation items - note Trips has α symbol, Settings is in user dropdown
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Trips/ })).toBeVisible(); // Match with α symbol
|
||||
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||
|
||||
// Settings is in user dropdown, not main nav - check user dropdown instead
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await expect(userDropdown).toBeVisible();
|
||||
|
||||
// Check for "My data" dropdown - select the visible one (not hidden mobile version)
|
||||
await expect(page.getByText('My data').and(page.locator(':visible'))).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Map section', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
// No h1 heading on map page - check for map interface instead
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Trips section', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
// No h1 heading on trips page - check for trips interface instead (visible elements only)
|
||||
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Stats section', async ({ page }) => {
|
||||
await helpers.navigateTo('Stats');
|
||||
|
||||
await expect(page).toHaveURL(/\/stats/);
|
||||
// No h1 heading on stats page - check for stats interface instead (visible elements only)
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Settings section', async ({ page }) => {
|
||||
await helpers.navigateTo('Settings');
|
||||
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
// No h1 heading on settings page - check for settings interface instead
|
||||
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('My Data Dropdown', () => {
|
||||
test('should expand My data dropdown', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Click on "My data" dropdown - select the visible one (not hidden mobile version)
|
||||
await page.getByText('My data').and(page.locator(':visible')).click();
|
||||
|
||||
// Should show dropdown items
|
||||
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Visits' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Exports' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Points', async ({ page }) => {
|
||||
await helpers.navigateTo('Points');
|
||||
|
||||
await expect(page).toHaveURL(/\/points/);
|
||||
// No h1 heading on points page - check for points interface instead (visible elements only)
|
||||
await expect(page.getByText(/point|location|coordinate/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Visits', async ({ page }) => {
|
||||
await helpers.navigateTo('Visits');
|
||||
|
||||
await expect(page).toHaveURL(/\/visits/);
|
||||
// No h1 heading on visits page - check for visits interface instead (visible elements only)
|
||||
await expect(page.getByText(/visit|place|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Imports', async ({ page }) => {
|
||||
await helpers.navigateTo('Imports');
|
||||
|
||||
await expect(page).toHaveURL(/\/imports/);
|
||||
// No h1 heading on imports page - check for imports interface instead (visible elements only)
|
||||
await expect(page.getByText(/import|file|source/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Exports', async ({ page }) => {
|
||||
await helpers.navigateTo('Exports');
|
||||
|
||||
await expect(page).toHaveURL(/\/exports/);
|
||||
// No h1 heading on exports page - check for exports interface instead (visible elements only)
|
||||
await expect(page.getByText(/export|download|format/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('User Navigation', () => {
|
||||
test('should display user menu', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Click on user dropdown using the details/summary structure
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
|
||||
// Should show user menu items
|
||||
await expect(page.getByRole('link', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to Account settings', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/users\/edit/);
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show logout functionality', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||
await userDropdown.locator('summary').click();
|
||||
await page.getByRole('link', { name: 'Logout' }).click();
|
||||
|
||||
// Should redirect to home/login
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Breadcrumb Navigation', () => {
|
||||
test('should show breadcrumbs on detail pages', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip links
|
||||
const tripLinks = page.getByRole('link').filter({ hasText: /trip|km|miles/i });
|
||||
const linkCount = await tripLinks.count();
|
||||
|
||||
if (linkCount > 0) {
|
||||
// Click on first trip
|
||||
await tripLinks.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show breadcrumb navigation
|
||||
const breadcrumbs = page.locator('.breadcrumb, .breadcrumbs, nav').filter({ hasText: /trip/i });
|
||||
if (await breadcrumbs.isVisible()) {
|
||||
await expect(breadcrumbs).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate back using breadcrumbs', async ({ page }) => {
|
||||
await helpers.navigateTo('Imports');
|
||||
|
||||
// Look for import detail links
|
||||
const importLinks = page.getByRole('link').filter({ hasText: /\.json|\.gpx|\.rec/i });
|
||||
const linkCount = await importLinks.count();
|
||||
|
||||
if (linkCount > 0) {
|
||||
await importLinks.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for back navigation
|
||||
const backLink = page.getByRole('link', { name: /back|imports/i });
|
||||
if (await backLink.isVisible()) {
|
||||
await backLink.click();
|
||||
await expect(page).toHaveURL(/\/imports/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('URL Navigation', () => {
|
||||
test('should handle direct URL navigation', async ({ page }) => {
|
||||
// Navigate directly to different sections - no h1 headings on pages
|
||||
await page.goto('/map');
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
|
||||
await page.goto('/trips');
|
||||
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
|
||||
await page.goto('/stats');
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle browser back/forward navigation', async ({ page }) => {
|
||||
// Navigate to different pages
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.navigateTo('Trips');
|
||||
await helpers.navigateTo('Stats');
|
||||
|
||||
// Use browser back
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
|
||||
// Use browser forward
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/trips/);
|
||||
});
|
||||
|
||||
test('should handle URL parameters', async ({ page }) => {
|
||||
// Navigate to map with date parameters
|
||||
await page.goto('/map?start_at=2024-01-01T00:00&end_at=2024-01-02T23:59');
|
||||
|
||||
// Should preserve URL parameters
|
||||
await expect(page).toHaveURL(/start_at=2024-01-01/);
|
||||
await expect(page).toHaveURL(/end_at=2024-01-02/);
|
||||
|
||||
// Form should be populated with URL parameters - use display labels
|
||||
await expect(page.getByLabel('Start at')).toHaveValue(/2024-01-01/);
|
||||
await expect(page.getByLabel('End at')).toHaveValue(/2024-01-02/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test('should show mobile navigation menu', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Look for mobile menu button (hamburger)
|
||||
const mobileMenuButton = page.locator('button').filter({ hasText: /menu|☰|≡/ }).first();
|
||||
|
||||
if (await mobileMenuButton.isVisible()) {
|
||||
await mobileMenuButton.click();
|
||||
|
||||
// Should show mobile navigation
|
||||
await expect(page.getByRole('link', { name: 'Map' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Trips' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile navigation interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Open mobile navigation
|
||||
await helpers.openMobileNavigation();
|
||||
|
||||
// Navigate to different section
|
||||
await page.getByRole('link', { name: 'Stats' }).click();
|
||||
|
||||
// Should navigate successfully - no h1 heading on stats page
|
||||
await expect(page).toHaveURL(/\/stats/);
|
||||
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle mobile dropdown menus', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Open mobile navigation
|
||||
await helpers.openMobileNavigation();
|
||||
|
||||
// Look for "My data" in mobile menu - select the visible one
|
||||
const myDataMobile = page.getByText('My data').and(page.locator(':visible'));
|
||||
if (await myDataMobile.isVisible()) {
|
||||
await myDataMobile.click();
|
||||
|
||||
// Should show mobile dropdown
|
||||
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Active Navigation State', () => {
|
||||
test('should highlight active navigation item', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Map should be active - use exact match to avoid attribution links
|
||||
const mapLink = page.getByRole('link', { name: 'Map', exact: true });
|
||||
await expect(mapLink).toHaveClass(/active|current/);
|
||||
|
||||
// Navigate to different section
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Trips should now be active
|
||||
const tripsLink = page.getByRole('link', { name: 'Trips' });
|
||||
await expect(tripsLink).toHaveClass(/active|current/);
|
||||
});
|
||||
|
||||
test('should update active state on URL change', async ({ page }) => {
|
||||
// Navigate directly via URL
|
||||
await page.goto('/stats');
|
||||
|
||||
// Stats should be active - use exact match to avoid "Update stats" button
|
||||
const statsLink = page.getByRole('link', { name: 'Stats', exact: true });
|
||||
await expect(statsLink).toHaveClass(/active|current/);
|
||||
|
||||
// Navigate via URL again
|
||||
await page.goto('/settings');
|
||||
|
||||
// Settings link is in user dropdown, not main nav - check URL instead
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation Performance', () => {
|
||||
test('should navigate between sections quickly', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Navigate through multiple sections (Settings uses different navigation)
|
||||
await helpers.navigateTo('Map');
|
||||
await helpers.navigateTo('Trips');
|
||||
await helpers.navigateTo('Stats');
|
||||
await helpers.navigateTo('Points'); // Navigate to Points instead of Settings
|
||||
|
||||
const endTime = Date.now();
|
||||
const totalTime = endTime - startTime;
|
||||
|
||||
// Should complete navigation within reasonable time
|
||||
expect(totalTime).toBeLessThan(10000); // 10 seconds
|
||||
});
|
||||
|
||||
test('should handle rapid navigation clicks', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Rapidly click different navigation items (Settings is not in main nav)
|
||||
await page.getByRole('link', { name: /Trips/ }).click(); // Match with α symbol
|
||||
await page.getByRole('link', { name: 'Stats' }).click();
|
||||
await page.getByRole('link', { name: 'Map', exact: true }).click();
|
||||
|
||||
// Should end up on the last clicked item
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should handle non-existent routes', async ({ page }) => {
|
||||
// Navigate to a non-existent route
|
||||
await page.goto('/non-existent-page');
|
||||
|
||||
// Should show 404 or redirect to valid page
|
||||
const currentUrl = page.url();
|
||||
|
||||
// Either shows 404 page or redirects to valid page
|
||||
if (currentUrl.includes('non-existent-page')) {
|
||||
// Should show 404 page
|
||||
await expect(page.getByText(/404|not found/i)).toBeVisible();
|
||||
} else {
|
||||
// Should redirect to valid page
|
||||
expect(currentUrl).toMatch(/\/(map|home|$)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Mock network error for navigation
|
||||
await page.route('**/trips', route => route.abort());
|
||||
|
||||
// Try to navigate
|
||||
await page.getByRole('link', { name: 'Trips' }).click();
|
||||
|
||||
// Should handle gracefully (stay on current page or show error)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should not crash - page should still be functional - use exact match
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
test('should support keyboard navigation', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Press Tab to navigate to links
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Should focus on navigation elements
|
||||
const focusedElement = page.locator(':focus');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
|
||||
// Should be able to navigate with keyboard
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should navigate to focused element - use exact match to avoid attribution links
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle keyboard shortcuts', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Test common keyboard shortcuts if they exist
|
||||
// This depends on the application implementing keyboard shortcuts
|
||||
|
||||
// For example, if there's a keyboard shortcut for settings
|
||||
await page.keyboard.press('Alt+S');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// May or may not navigate (depends on implementation)
|
||||
const currentUrl = page.url();
|
||||
|
||||
// Just verify the page is still functional - use exact match
|
||||
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have proper ARIA labels', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// Check for main navigation landmark
|
||||
const mainNav = page.locator('nav[role="navigation"]').or(page.locator('nav'));
|
||||
await expect(mainNav.first()).toBeVisible();
|
||||
|
||||
// Check for accessible navigation items
|
||||
const navItems = page.getByRole('link');
|
||||
const navCount = await navItems.count();
|
||||
|
||||
expect(navCount).toBeGreaterThan(0);
|
||||
|
||||
// Navigation items should have proper text content
|
||||
for (let i = 0; i < Math.min(navCount, 5); i++) {
|
||||
const navItem = navItems.nth(i);
|
||||
const text = await navItem.textContent();
|
||||
expect(text).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should support screen reader navigation', async ({ page }) => {
|
||||
await helpers.navigateTo('Map');
|
||||
|
||||
// No h1 headings exist - check for navigation landmark instead
|
||||
const nav = page.locator('nav').first();
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Check for proper link labels
|
||||
const links = page.getByRole('link');
|
||||
const linkCount = await links.count();
|
||||
|
||||
// Most links should have text content (skip icon-only links)
|
||||
let linksWithText = 0;
|
||||
for (let i = 0; i < Math.min(linkCount, 10); i++) {
|
||||
const link = links.nth(i);
|
||||
const text = await link.textContent();
|
||||
if (text?.trim()) {
|
||||
linksWithText++;
|
||||
}
|
||||
}
|
||||
// At least half of the links should have text content
|
||||
expect(linksWithText).toBeGreaterThan(Math.min(linkCount, 10) / 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
418
e2e/trips.spec.ts
Normal file
418
e2e/trips.spec.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { TestHelpers } from './fixtures/test-helpers';
|
||||
|
||||
test.describe('Trips', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
helpers = new TestHelpers(page);
|
||||
await helpers.loginAsDemo();
|
||||
});
|
||||
|
||||
test.describe('Trips List', () => {
|
||||
test('should display trips page correctly', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Check page title and elements
|
||||
await expect(page).toHaveTitle(/Trips.*Dawarich/);
|
||||
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||
|
||||
// Should show "New trip" button
|
||||
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show trips list or empty state', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Check for either trips grid or empty state
|
||||
const tripsGrid = page.locator('.grid');
|
||||
const emptyState = page.getByText('Hello there!');
|
||||
|
||||
if (await tripsGrid.isVisible()) {
|
||||
await expect(tripsGrid).toBeVisible();
|
||||
} else {
|
||||
// Should show empty state with create link
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'create one' })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display trip statistics if trips exist', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip cards
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Should show distance info in first trip card
|
||||
const firstCard = tripCards.first();
|
||||
await expect(firstCard.getByText(/\d+\s*(km|miles)/)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate to new trip page', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Click "New trip" button
|
||||
await page.getByRole('link', { name: 'New trip' }).click();
|
||||
|
||||
// Should navigate to new trip page
|
||||
await expect(page).toHaveURL(/\/trips\/new/);
|
||||
await expect(page.getByRole('heading', { name: 'New trip' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Creation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
await page.getByRole('link', { name: 'New trip' }).click();
|
||||
});
|
||||
|
||||
test('should show trip creation form', async ({ page }) => {
|
||||
// Should have form fields
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||
|
||||
// Should have submit button
|
||||
await expect(page.getByRole('button', { name: 'Create trip' })).toBeVisible();
|
||||
|
||||
// Should have map container
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create trip with valid data', async ({ page }) => {
|
||||
// Fill form fields
|
||||
await page.getByLabel('Name').fill('Test Trip');
|
||||
await page.getByLabel('Started at').fill('2024-01-01T10:00');
|
||||
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should redirect to trip show page
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/);
|
||||
});
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
// Try to submit empty form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByText(/can't be blank|is required/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate date range', async ({ page }) => {
|
||||
// Fill with invalid date range (end before start)
|
||||
await page.getByLabel('Name').fill('Invalid Trip');
|
||||
await page.getByLabel('Started at').fill('2024-01-02T10:00');
|
||||
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||
|
||||
// Should show validation error (if backend validates this)
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Note: This test assumes backend validation exists
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Details', () => {
|
||||
test('should display trip details when clicked', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Look for trip cards
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click on first trip card
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show trip name as heading
|
||||
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||
|
||||
// Should show distance info
|
||||
const distanceText = page.getByText(/\d+\s*(km|miles)/);
|
||||
if (await distanceText.count() > 0) {
|
||||
await expect(distanceText.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip map', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show map container
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await expect(mapContainer).toBeVisible();
|
||||
await helpers.waitForMap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip timeline info', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show date/time information
|
||||
const dateInfo = page.getByText(/\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)/);
|
||||
if (await dateInfo.count() > 0) {
|
||||
await expect(dateInfo.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow trip editing', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for edit link/button
|
||||
const editLink = page.getByRole('link', { name: /edit/i });
|
||||
if (await editLink.isVisible()) {
|
||||
await editLink.click();
|
||||
|
||||
// Should show edit form
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Visualization', () => {
|
||||
test('should show trip on map', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if map is present
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Should have map controls
|
||||
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should display trip route', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Look for route polylines
|
||||
const routeElements = page.locator('.leaflet-interactive[stroke]');
|
||||
if (await routeElements.count() > 0) {
|
||||
await expect(routeElements.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show trip points', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Look for point markers
|
||||
const pointMarkers = page.locator('.leaflet-marker-icon');
|
||||
if (await pointMarkers.count() > 0) {
|
||||
await expect(pointMarkers.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow map interaction', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Test zoom controls
|
||||
const zoomIn = page.getByRole('button', { name: 'Zoom in' });
|
||||
const zoomOut = page.getByRole('button', { name: 'Zoom out' });
|
||||
|
||||
await zoomIn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await zoomOut.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Map should still be functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Management', () => {
|
||||
test('should show trip actions', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for edit/delete/export options
|
||||
const editLink = page.getByRole('link', { name: /edit/i });
|
||||
const deleteButton = page.getByRole('button', { name: /delete/i }).or(page.getByRole('link', { name: /delete/i }));
|
||||
|
||||
// At least edit should be available
|
||||
if (await editLink.isVisible()) {
|
||||
await expect(editLink).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Trips Experience', () => {
|
||||
test('should work on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Page should load correctly on mobile
|
||||
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||
|
||||
// Grid should adapt to mobile
|
||||
const tripsGrid = page.locator('.grid');
|
||||
if (await tripsGrid.isVisible()) {
|
||||
await expect(tripsGrid).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile trip details', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show trip info on mobile
|
||||
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||
|
||||
// Map should be responsive if present
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mobile map interactions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const tripCards = page.locator('.card[data-trip-id]');
|
||||
const cardCount = await tripCards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
await tripCards.first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const mapContainer = page.locator('#map');
|
||||
if (await mapContainer.isVisible()) {
|
||||
await helpers.waitForMap();
|
||||
|
||||
// Test touch interaction
|
||||
await mapContainer.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Map should remain functional
|
||||
await expect(mapContainer).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Trip Performance', () => {
|
||||
test('should load trips page within reasonable time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
const maxLoadTime = await helpers.isMobileViewport() ? 15000 : 10000;
|
||||
|
||||
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||
});
|
||||
|
||||
test('should handle large numbers of trips', async ({ page }) => {
|
||||
await helpers.navigateTo('Trips');
|
||||
|
||||
// Page should load without timing out
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
|
||||
// Should show either trips or empty state
|
||||
const tripsGrid = page.locator('.grid');
|
||||
const emptyState = page.getByText('Hello there!');
|
||||
|
||||
expect(await tripsGrid.isVisible() || await emptyState.isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
playwright.config.ts
Normal file
69
playwright.config.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Take screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
/* Record video on failure */
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* Set timeout for actions */
|
||||
actionTimeout: 10000,
|
||||
|
||||
/* Set timeout for page navigation */
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
/* Global setup for checking server availability */
|
||||
globalSetup: require.resolve('./e2e/global-setup.ts'),
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
27
playwright.yml
Normal file
27
playwright.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
|
@ -4,13 +4,13 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let!(:user) { create(:user) }
|
||||
let!(:area) { create(:area, user: user) }
|
||||
let(:user) { create(:user) }
|
||||
let(:area) { create(:area, user: user) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id)
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
|
||||
|
||||
described_class.new.perform_now
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,5 +28,13 @@ RSpec.describe Owntracks::PointCreatingJob, type: :job do
|
|||
expect { perform }.not_to(change { Point.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when point is invalid' do
|
||||
let(:point_params) { { lat: 1.0, lon: 1.0, tid: 'test', tst: nil, topic: 'iPhone 12 pro' } }
|
||||
|
||||
it 'does not create a point' do
|
||||
expect { perform }.not_to(change { Point.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,12 +14,10 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
allow(generator_instance).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
end
|
||||
|
||||
it 'calls the generator and creates a notification' do
|
||||
# Mock the generator to return the count of tracks created
|
||||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
|
|
@ -48,12 +46,10 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
allow(generator_instance).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
allow(generator_instance).to receive(:call).and_return(1)
|
||||
end
|
||||
|
||||
it 'passes custom parameters to the generator' do
|
||||
# Mock generator to return the count of tracks created
|
||||
allow(generator_instance).to receive(:call).and_return(1)
|
||||
|
||||
described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
|
|
@ -73,72 +69,6 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with mode translation' do
|
||||
before do
|
||||
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||
allow(generator_instance).to receive(:call) # No tracks created for mode tests
|
||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||
allow(notification_service).to receive(:call)
|
||||
end
|
||||
|
||||
it 'translates :none to :incremental' do
|
||||
allow(generator_instance).to receive(:call).and_return(0)
|
||||
|
||||
described_class.new.perform(user.id, mode: :none)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :incremental
|
||||
)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
end
|
||||
|
||||
it 'translates :daily to :daily' do
|
||||
allow(generator_instance).to receive(:call).and_return(0)
|
||||
|
||||
described_class.new.perform(user.id, mode: :daily)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :daily
|
||||
)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
end
|
||||
|
||||
it 'translates other modes to :bulk' do
|
||||
allow(generator_instance).to receive(:call).and_return(0)
|
||||
|
||||
described_class.new.perform(user.id, mode: :replace)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
mode: :bulk
|
||||
)
|
||||
expect(Notifications::Create).to have_received(:new).with(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Tracks Generated',
|
||||
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when generator raises an error' do
|
||||
let(:error_message) { 'Something went wrong' }
|
||||
let(:notification_service) { instance_double(Notifications::Create) }
|
||||
|
|
@ -175,12 +105,13 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'handles the error gracefully and creates error notification' do
|
||||
before do
|
||||
allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
|
||||
end
|
||||
|
||||
# Should not raise an error because it's caught by the rescue block
|
||||
it 'handles the error gracefully and creates error notification' do
|
||||
expect { described_class.new.perform(999) }.not_to raise_error
|
||||
|
||||
expect(ExceptionReporter).to have_received(:call)
|
||||
|
|
@ -188,15 +119,14 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
end
|
||||
|
||||
context 'when tracks are deleted and recreated' do
|
||||
it 'returns the correct count of newly created tracks' do
|
||||
# Create some existing tracks first
|
||||
create_list(:track, 3, user: user)
|
||||
let(:existing_tracks) { create_list(:track, 3, user: user) }
|
||||
|
||||
# Mock the generator to simulate deleting existing tracks and creating new ones
|
||||
# This should return the count of newly created tracks, not the difference
|
||||
before do
|
||||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
end
|
||||
|
||||
described_class.new.perform(user.id, mode: :bulk)
|
||||
it 'returns the correct count of newly created tracks' do
|
||||
described_class.new.perform(user.id, mode: :incremental)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
user,
|
||||
|
|
|
|||
|
|
@ -41,9 +41,6 @@ RSpec.configure do |config|
|
|||
|
||||
config.before(:suite) do
|
||||
Rails.application.reload_routes!
|
||||
|
||||
# DatabaseCleaner.strategy = :transaction
|
||||
# DatabaseCleaner.clean_with(:truncation)
|
||||
end
|
||||
|
||||
config.before do
|
||||
|
|
@ -92,12 +89,6 @@ RSpec.configure do |config|
|
|||
config.after(:suite) do
|
||||
Rake::Task['rswag:generate'].invoke
|
||||
end
|
||||
|
||||
# config.around(:each) do |example|
|
||||
# DatabaseCleaner.cleaning do
|
||||
# example.run
|
||||
# end
|
||||
# end
|
||||
end
|
||||
|
||||
Shoulda::Matchers.configure do |config|
|
||||
|
|
|
|||
|
|
@ -5,95 +5,166 @@ require 'rails_helper'
|
|||
RSpec.describe TrackSerializer do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
let(:track) { create(:track, user: user) }
|
||||
let(:serializer) { described_class.new(track) }
|
||||
|
||||
context 'when serializing user tracks with track IDs' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
subject(:serialized_track) { serializer.call }
|
||||
|
||||
let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) }
|
||||
let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) }
|
||||
let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) }
|
||||
let(:track_ids) { [track1.id, track2.id] }
|
||||
|
||||
it 'returns an array of serialized tracks' do
|
||||
expect(serializer).to be_an(Array)
|
||||
expect(serializer.length).to eq(2)
|
||||
end
|
||||
|
||||
it 'serializes each track correctly' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(track1.id, track2.id)
|
||||
expect(serialized_ids).not_to include(track3.id)
|
||||
end
|
||||
|
||||
it 'formats timestamps as ISO8601 for all tracks' do
|
||||
serializer.each do |track|
|
||||
expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes all required fields for each track' do
|
||||
serializer.each do |track|
|
||||
expect(track.keys).to contain_exactly(
|
||||
it 'returns a hash with all required attributes' do
|
||||
expect(serialized_track).to be_a(Hash)
|
||||
expect(serialized_track.keys).to contain_exactly(
|
||||
:id, :start_at, :end_at, :distance, :avg_speed, :duration,
|
||||
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path
|
||||
)
|
||||
end
|
||||
|
||||
it 'serializes the track ID correctly' do
|
||||
expect(serialized_track[:id]).to eq(track.id)
|
||||
end
|
||||
|
||||
it 'handles numeric values correctly' do
|
||||
serializer.each do |track|
|
||||
expect(track[:distance]).to be_a(Numeric)
|
||||
expect(track[:avg_speed]).to be_a(Numeric)
|
||||
expect(track[:duration]).to be_a(Numeric)
|
||||
expect(track[:elevation_gain]).to be_a(Numeric)
|
||||
expect(track[:elevation_loss]).to be_a(Numeric)
|
||||
expect(track[:elevation_max]).to be_a(Numeric)
|
||||
expect(track[:elevation_min]).to be_a(Numeric)
|
||||
it 'formats start_at as ISO8601 timestamp' do
|
||||
expect(serialized_track[:start_at]).to eq(track.start_at.iso8601)
|
||||
expect(serialized_track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
end
|
||||
|
||||
it 'formats end_at as ISO8601 timestamp' do
|
||||
expect(serialized_track[:end_at]).to eq(track.end_at.iso8601)
|
||||
expect(serialized_track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
end
|
||||
|
||||
it 'converts distance to integer' do
|
||||
expect(serialized_track[:distance]).to eq(track.distance.to_i)
|
||||
expect(serialized_track[:distance]).to be_a(Integer)
|
||||
end
|
||||
|
||||
it 'converts avg_speed to float' do
|
||||
expect(serialized_track[:avg_speed]).to eq(track.avg_speed.to_f)
|
||||
expect(serialized_track[:avg_speed]).to be_a(Float)
|
||||
end
|
||||
|
||||
it 'serializes duration as numeric value' do
|
||||
expect(serialized_track[:duration]).to eq(track.duration)
|
||||
expect(serialized_track[:duration]).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'serializes elevation_gain as numeric value' do
|
||||
expect(serialized_track[:elevation_gain]).to eq(track.elevation_gain)
|
||||
expect(serialized_track[:elevation_gain]).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'serializes elevation_loss as numeric value' do
|
||||
expect(serialized_track[:elevation_loss]).to eq(track.elevation_loss)
|
||||
expect(serialized_track[:elevation_loss]).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'serializes elevation_max as numeric value' do
|
||||
expect(serialized_track[:elevation_max]).to eq(track.elevation_max)
|
||||
expect(serialized_track[:elevation_max]).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'serializes elevation_min as numeric value' do
|
||||
expect(serialized_track[:elevation_min]).to eq(track.elevation_min)
|
||||
expect(serialized_track[:elevation_min]).to be_a(Numeric)
|
||||
end
|
||||
|
||||
it 'converts original_path to string' do
|
||||
expect(serialized_track[:original_path]).to eq(track.original_path.to_s)
|
||||
expect(serialized_track[:original_path]).to be_a(String)
|
||||
end
|
||||
|
||||
context 'with decimal distance values' do
|
||||
let(:track) { create(:track, user: user, distance: 1234.56) }
|
||||
|
||||
it 'truncates distance to integer' do
|
||||
expect(serialized_track[:distance]).to eq(1234)
|
||||
end
|
||||
end
|
||||
|
||||
it 'orders tracks by start_at in ascending order' do
|
||||
serialized_tracks = serializer
|
||||
expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago
|
||||
expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago
|
||||
context 'with decimal avg_speed values' do
|
||||
let(:track) { create(:track, user: user, avg_speed: 25.75) }
|
||||
|
||||
it 'converts avg_speed to float' do
|
||||
expect(serialized_track[:avg_speed]).to eq(25.75)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs belong to different users' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
context 'with different original_path formats' do
|
||||
let(:track) { create(:track, user: user, original_path: 'LINESTRING(0 0, 1 1, 2 2)') }
|
||||
|
||||
let(:other_user) { create(:user) }
|
||||
let!(:user_track) { create(:track, user: user) }
|
||||
let!(:other_user_track) { create(:track, user: other_user) }
|
||||
let(:track_ids) { [user_track.id, other_user_track.id] }
|
||||
|
||||
it 'only returns tracks belonging to the specified user' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(user_track.id)
|
||||
expect(serialized_ids).not_to include(other_user_track.id)
|
||||
it 'converts geometry to WKT string format' do
|
||||
expect(serialized_track[:original_path]).to eq('LINESTRING (0 0, 1 1, 2 2)')
|
||||
expect(serialized_track[:original_path]).to be_a(String)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs array is empty' do
|
||||
subject(:serializer) { described_class.new(user, []).call }
|
||||
context 'with zero values' do
|
||||
let(:track) do
|
||||
create(:track, user: user,
|
||||
distance: 0,
|
||||
avg_speed: 0.0,
|
||||
duration: 0,
|
||||
elevation_gain: 0,
|
||||
elevation_loss: 0,
|
||||
elevation_max: 0,
|
||||
elevation_min: 0)
|
||||
end
|
||||
|
||||
it 'returns an empty array' do
|
||||
expect(serializer).to eq([])
|
||||
it 'handles zero values correctly' do
|
||||
expect(serialized_track[:distance]).to eq(0)
|
||||
expect(serialized_track[:avg_speed]).to eq(0.0)
|
||||
expect(serialized_track[:duration]).to eq(0)
|
||||
expect(serialized_track[:elevation_gain]).to eq(0)
|
||||
expect(serialized_track[:elevation_loss]).to eq(0)
|
||||
expect(serialized_track[:elevation_max]).to eq(0)
|
||||
expect(serialized_track[:elevation_min]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs contain non-existent IDs' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
|
||||
let!(:existing_track) { create(:track, user: user) }
|
||||
let(:track_ids) { [existing_track.id, 999999] }
|
||||
|
||||
it 'only returns existing tracks' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(existing_track.id)
|
||||
expect(serializer.length).to eq(1)
|
||||
context 'with very large values' do
|
||||
let(:track) do
|
||||
create(:track, user: user,
|
||||
distance: 1_000_000.0,
|
||||
avg_speed: 999.99,
|
||||
duration: 86_400, # 24 hours in seconds
|
||||
elevation_gain: 10_000,
|
||||
elevation_loss: 8_000,
|
||||
elevation_max: 5_000,
|
||||
elevation_min: 0)
|
||||
end
|
||||
|
||||
it 'handles large values correctly' do
|
||||
expect(serialized_track[:distance]).to eq(1_000_000)
|
||||
expect(serialized_track[:avg_speed]).to eq(999.99)
|
||||
expect(serialized_track[:duration]).to eq(86_400)
|
||||
expect(serialized_track[:elevation_gain]).to eq(10_000)
|
||||
expect(serialized_track[:elevation_loss]).to eq(8_000)
|
||||
expect(serialized_track[:elevation_max]).to eq(5_000)
|
||||
expect(serialized_track[:elevation_min]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different timestamp formats' do
|
||||
let(:start_time) { Time.current }
|
||||
let(:end_time) { start_time + 1.hour }
|
||||
let(:track) { create(:track, user: user, start_at: start_time, end_at: end_time) }
|
||||
|
||||
it 'formats timestamps consistently' do
|
||||
expect(serialized_track[:start_at]).to eq(start_time.iso8601)
|
||||
expect(serialized_track[:end_at]).to eq(end_time.iso8601)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
let(:track) { create(:track) }
|
||||
|
||||
it 'accepts a track parameter' do
|
||||
expect { described_class.new(track) }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'stores the track instance' do
|
||||
serializer = described_class.new(track)
|
||||
expect(serializer.instance_variable_get(:@track)).to eq(track)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
99
spec/serializers/tracks_serializer_spec.rb
Normal file
99
spec/serializers/tracks_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TracksSerializer do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
context 'when serializing user tracks with track IDs' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
|
||||
let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.hour.ago) }
|
||||
let!(:track2) { create(:track, user: user, start_at: 4.hours.ago, end_at: 3.hours.ago) }
|
||||
let!(:track3) { create(:track, user: user, start_at: 6.hours.ago, end_at: 5.hours.ago) }
|
||||
let(:track_ids) { [track1.id, track2.id] }
|
||||
|
||||
it 'returns an array of serialized tracks' do
|
||||
expect(serializer).to be_an(Array)
|
||||
expect(serializer.length).to eq(2)
|
||||
end
|
||||
|
||||
it 'serializes each track correctly' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(track1.id, track2.id)
|
||||
expect(serialized_ids).not_to include(track3.id)
|
||||
end
|
||||
|
||||
it 'formats timestamps as ISO8601 for all tracks' do
|
||||
serializer.each do |track|
|
||||
expect(track[:start_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
expect(track[:end_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes all required fields for each track' do
|
||||
serializer.each do |track|
|
||||
expect(track.keys).to contain_exactly(
|
||||
:id, :start_at, :end_at, :distance, :avg_speed, :duration,
|
||||
:elevation_gain, :elevation_loss, :elevation_max, :elevation_min, :original_path
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'handles numeric values correctly' do
|
||||
serializer.each do |track|
|
||||
expect(track[:distance]).to be_a(Numeric)
|
||||
expect(track[:avg_speed]).to be_a(Numeric)
|
||||
expect(track[:duration]).to be_a(Numeric)
|
||||
expect(track[:elevation_gain]).to be_a(Numeric)
|
||||
expect(track[:elevation_loss]).to be_a(Numeric)
|
||||
expect(track[:elevation_max]).to be_a(Numeric)
|
||||
expect(track[:elevation_min]).to be_a(Numeric)
|
||||
end
|
||||
end
|
||||
|
||||
it 'orders tracks by start_at in ascending order' do
|
||||
serialized_tracks = serializer
|
||||
expect(serialized_tracks.first[:id]).to eq(track2.id) # Started 4 hours ago
|
||||
expect(serialized_tracks.second[:id]).to eq(track1.id) # Started 2 hours ago
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs belong to different users' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
|
||||
let(:other_user) { create(:user) }
|
||||
let!(:user_track) { create(:track, user: user) }
|
||||
let!(:other_user_track) { create(:track, user: other_user) }
|
||||
let(:track_ids) { [user_track.id, other_user_track.id] }
|
||||
|
||||
it 'only returns tracks belonging to the specified user' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(user_track.id)
|
||||
expect(serialized_ids).not_to include(other_user_track.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs array is empty' do
|
||||
subject(:serializer) { described_class.new(user, []).call }
|
||||
|
||||
it 'returns an empty array' do
|
||||
expect(serializer).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when track IDs contain non-existent IDs' do
|
||||
subject(:serializer) { described_class.new(user, track_ids).call }
|
||||
|
||||
let!(:existing_track) { create(:track, user: user) }
|
||||
let(:track_ids) { [existing_track.id, 999999] }
|
||||
|
||||
it 'only returns existing tracks' do
|
||||
serialized_ids = serializer.map { |track| track[:id] }
|
||||
expect(serialized_ids).to contain_exactly(existing_track.id)
|
||||
expect(serializer.length).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -185,5 +185,13 @@ RSpec.describe OwnTracks::Params do
|
|||
expect(params[:trigger]).to eq('unknown')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when point is invalid' do
|
||||
let(:raw_point_params) { super().merge(lon: nil, lat: nil, tst: nil) }
|
||||
|
||||
it 'returns parsed params' do
|
||||
expect(params).to eq(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
|
||||
it 'processes first point' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: nil, mode: :none)
|
||||
.with(user.id, start_at: nil, end_at: nil, mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
end
|
||||
|
|
@ -47,7 +47,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
|
||||
it 'processes when time threshold exceeded' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
end
|
||||
|
|
@ -65,7 +65,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
|
||||
it 'uses existing track end time as start_at' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
end
|
||||
|
|
@ -88,7 +88,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
|
||||
it 'processes when distance threshold exceeded' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ RSpec.describe Visits::Suggest do
|
|||
end
|
||||
|
||||
context 'when reverse geocoding is enabled' do
|
||||
# Use a different time range to avoid interference with main tests
|
||||
let(:reverse_geocoding_start_at) { Time.zone.local(2020, 6, 1, 0, 0, 0) }
|
||||
let(:reverse_geocoding_end_at) { Time.zone.local(2020, 6, 1, 2, 0, 0) }
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
RSpec.configure do |config|
|
||||
config.before(:each) do
|
||||
# Clear the cache before each test
|
||||
Rails.cache.clear
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ describe 'Settings API', type: :request do
|
|||
'immich_api_key': 'your-immich-api-key',
|
||||
'photoprism_url': 'https://photoprism.example.com',
|
||||
'photoprism_api_key': 'your-photoprism-api-key',
|
||||
'maps': { 'distance_unit': 'km' },
|
||||
'visits_suggestions_enabled': true
|
||||
'speed_color_scale': 'viridis',
|
||||
'fog_of_war_threshold': 100
|
||||
}
|
||||
}
|
||||
tags 'Settings'
|
||||
|
|
@ -100,21 +100,15 @@ describe 'Settings API', type: :request do
|
|||
example: 'your-photoprism-api-key',
|
||||
description: 'API key for PhotoPrism photo service'
|
||||
},
|
||||
maps: {
|
||||
type: :object,
|
||||
properties: {
|
||||
distance_unit: {
|
||||
speed_color_scale: {
|
||||
type: :string,
|
||||
example: 'km',
|
||||
description: 'Distance unit preference (km or miles)'
|
||||
}
|
||||
example: 'viridis',
|
||||
description: 'Color scale for speed-colored routes'
|
||||
},
|
||||
description: 'Map-related settings'
|
||||
},
|
||||
visits_suggestions_enabled: {
|
||||
type: :boolean,
|
||||
example: true,
|
||||
description: 'Whether visit suggestions are enabled'
|
||||
fog_of_war_threshold: {
|
||||
type: :number,
|
||||
example: 100,
|
||||
description: 'Fog of war threshold value'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,33 +132,33 @@ describe 'Settings API', type: :request do
|
|||
type: :object,
|
||||
properties: {
|
||||
route_opacity: {
|
||||
type: :string,
|
||||
example: '60',
|
||||
type: :number,
|
||||
example: 60,
|
||||
description: 'Route opacity percentage (0-100)'
|
||||
},
|
||||
meters_between_routes: {
|
||||
type: :string,
|
||||
example: '500',
|
||||
type: :number,
|
||||
example: 500,
|
||||
description: 'Minimum distance between routes in meters'
|
||||
},
|
||||
minutes_between_routes: {
|
||||
type: :string,
|
||||
example: '30',
|
||||
type: :number,
|
||||
example: 30,
|
||||
description: 'Minimum time between routes in minutes'
|
||||
},
|
||||
fog_of_war_meters: {
|
||||
type: :string,
|
||||
example: '50',
|
||||
type: :number,
|
||||
example: 50,
|
||||
description: 'Fog of war radius in meters'
|
||||
},
|
||||
time_threshold_minutes: {
|
||||
type: :string,
|
||||
example: '30',
|
||||
type: :number,
|
||||
example: 30,
|
||||
description: 'Time threshold for grouping points in minutes'
|
||||
},
|
||||
merge_threshold_minutes: {
|
||||
type: :string,
|
||||
example: '15',
|
||||
type: :number,
|
||||
example: 15,
|
||||
description: 'Threshold for merging nearby points in minutes'
|
||||
},
|
||||
preferred_map_layer: {
|
||||
|
|
@ -207,21 +201,15 @@ describe 'Settings API', type: :request do
|
|||
example: 'your-photoprism-api-key',
|
||||
description: 'API key for PhotoPrism photo service'
|
||||
},
|
||||
maps: {
|
||||
type: :object,
|
||||
properties: {
|
||||
distance_unit: {
|
||||
speed_color_scale: {
|
||||
type: :string,
|
||||
example: 'km',
|
||||
description: 'Distance unit preference (km or miles)'
|
||||
}
|
||||
example: 'viridis',
|
||||
description: 'Color scale for speed-colored routes'
|
||||
},
|
||||
description: 'Map-related settings'
|
||||
},
|
||||
visits_suggestions_enabled: {
|
||||
type: :boolean,
|
||||
example: true,
|
||||
description: 'Whether visit suggestions are enabled'
|
||||
fog_of_war_threshold: {
|
||||
type: :number,
|
||||
example: 100,
|
||||
description: 'Fog of war threshold value'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1059,18 +1059,14 @@ paths:
|
|||
type: string
|
||||
example: your-photoprism-api-key
|
||||
description: API key for PhotoPrism photo service
|
||||
maps:
|
||||
type: object
|
||||
properties:
|
||||
distance_unit:
|
||||
speed_color_scale:
|
||||
type: string
|
||||
example: km
|
||||
description: Distance unit preference (km or miles)
|
||||
description: Map-related settings
|
||||
visits_suggestions_enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether visit suggestions are enabled
|
||||
example: viridis
|
||||
description: Color scale for speed-colored routes
|
||||
fog_of_war_threshold:
|
||||
type: number
|
||||
example: 100
|
||||
description: Fog of war threshold value
|
||||
examples:
|
||||
'0':
|
||||
summary: Updates user settings
|
||||
|
|
@ -1090,9 +1086,8 @@ paths:
|
|||
immich_api_key: your-immich-api-key
|
||||
photoprism_url: https://photoprism.example.com
|
||||
photoprism_api_key: your-photoprism-api-key
|
||||
maps:
|
||||
distance_unit: km
|
||||
visits_suggestions_enabled: true
|
||||
speed_color_scale: viridis
|
||||
fog_of_war_threshold: 100
|
||||
get:
|
||||
summary: Retrieves user settings
|
||||
tags:
|
||||
|
|
@ -1116,28 +1111,28 @@ paths:
|
|||
type: object
|
||||
properties:
|
||||
route_opacity:
|
||||
type: string
|
||||
example: '60'
|
||||
type: number
|
||||
example: 60
|
||||
description: Route opacity percentage (0-100)
|
||||
meters_between_routes:
|
||||
type: string
|
||||
example: '500'
|
||||
type: number
|
||||
example: 500
|
||||
description: Minimum distance between routes in meters
|
||||
minutes_between_routes:
|
||||
type: string
|
||||
example: '30'
|
||||
type: number
|
||||
example: 30
|
||||
description: Minimum time between routes in minutes
|
||||
fog_of_war_meters:
|
||||
type: string
|
||||
example: '50'
|
||||
type: number
|
||||
example: 50
|
||||
description: Fog of war radius in meters
|
||||
time_threshold_minutes:
|
||||
type: string
|
||||
example: '30'
|
||||
type: number
|
||||
example: 30
|
||||
description: Time threshold for grouping points in minutes
|
||||
merge_threshold_minutes:
|
||||
type: string
|
||||
example: '15'
|
||||
type: number
|
||||
example: 15
|
||||
description: Threshold for merging nearby points in minutes
|
||||
preferred_map_layer:
|
||||
type: string
|
||||
|
|
@ -1172,18 +1167,14 @@ paths:
|
|||
type: string
|
||||
example: your-photoprism-api-key
|
||||
description: API key for PhotoPrism photo service
|
||||
maps:
|
||||
type: object
|
||||
properties:
|
||||
distance_unit:
|
||||
speed_color_scale:
|
||||
type: string
|
||||
example: km
|
||||
description: Distance unit preference (km or miles)
|
||||
description: Map-related settings
|
||||
visits_suggestions_enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether visit suggestions are enabled
|
||||
example: viridis
|
||||
description: Color scale for speed-colored routes
|
||||
fog_of_war_threshold:
|
||||
type: number
|
||||
example: 100
|
||||
description: Fog of war threshold value
|
||||
"/api/v1/stats":
|
||||
get:
|
||||
summary: Retrieves all stats
|
||||
|
|
|
|||
Loading…
Reference in a new issue