Compare commits

...

9 commits

Author SHA1 Message Date
Evgenii Burmakin
f2e8d03fb2
Merge f92f757a7a into 8b03b0c7f5 2025-07-20 20:34:07 +02:00
Eugene Burmakin
8b03b0c7f5 Recalculate stats after changing distance units 2025-07-20 19:14:20 +02:00
Eugene Burmakin
f969d5d3e6 Clean up some mess 2025-07-20 18:57:53 +02:00
Eugene Burmakin
708bca26eb Fix owntracks point creation 2025-07-20 17:43:55 +02:00
Eugene Burmakin
45713f46dc Fix domain in development and production 2025-07-20 17:31:31 +02:00
Evgenii Burmakin
3149767675
Merge pull request #1531 from Freika/fix/map-tracks-popup
Fix/map tracks popup
2025-07-20 17:31:26 +02:00
Eugene Burmakin
002b3bd635 Fix settings controller spec and tracks popup 2025-07-20 17:06:45 +02:00
Eugene Burmakin
f92f757a7a Merge branch 'dev', remote-tracking branch 'origin' into tests/playwright 2025-07-12 18:08:17 +02:00
Eugene Burmakin
f9c93c0d3c Add E2E tests for Dawarich 2025-07-12 15:24:10 +02:00
46 changed files with 3594 additions and 348 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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:))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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'])
end
update_visits_name(properties) if properties['name'].present?
@place
place
end
end
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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -113,7 +113,6 @@ class Users::SafeSettings
end
def distance_unit
# km or mi
settings.dig('maps', 'distance_unit')
end

View file

@ -50,7 +50,7 @@
</div>
</div>
<div class="card bg-base-300 w-96 shadow-xl m-5">
<div class="card bg-base-300 w-96 shadow-xl m-5">
<div class="card-body">
<h2 class="card-title">Visits suggestions</h2>
<p>Enable or disable visits suggestions. It's a background task that runs every day at 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.</p>

View file

@ -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?

View file

@ -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

View file

@ -5,11 +5,11 @@ SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
DISTANCE_UNITS = {
km: 1000, # to meters
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
APP_VERSION = File.read('.app_version').strip

530
dawarich_user_scenarios.md Normal file
View 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

View file

@ -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

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250709195003)
DataMigrate::Data.define(version: 20250720171241)

296
e2e/README.md Normal file
View 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
View 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/);
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -160,7 +160,7 @@ RSpec.describe Trip, type: :model do
end
end
describe '#recalculate_distance!' do
describe '#recalculate_distance!' do
it 'recalculates and saves the distance' do
original_distance = trip.distance

View file

@ -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|

View file

@ -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 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 'returns an array of serialized tracks' do
expect(serializer).to be_an(Array)
expect(serializer.length).to eq(2)
end
it 'serializes the track ID correctly' do
expect(serialized_track[:id]).to eq(track.id)
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 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 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 '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 '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 '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 '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 '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 '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
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
context 'when track IDs belong to different users' do
subject(:serializer) { described_class.new(user, track_ids).call }
context 'with decimal avg_speed values' do
let(:track) { create(:track, user: user, avg_speed: 25.75) }
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 avg_speed to float' do
expect(serialized_track[:avg_speed]).to eq(25.75)
end
end
context 'when track IDs array is empty' do
subject(:serializer) { described_class.new(user, []).call }
context 'with different original_path formats' do
let(:track) { create(:track, user: user, original_path: 'LINESTRING(0 0, 1 1, 2 2)') }
it 'returns an empty array' do
expect(serializer).to eq([])
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 contain non-existent IDs' do
subject(:serializer) { described_class.new(user, track_ids).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
let!(:existing_track) { create(:track, user: user) }
let(:track_ids) { [existing_track.id, 999999] }
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
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

View 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

View file

@ -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

View file

@ -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

View file

@ -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) }

View file

@ -2,7 +2,6 @@
RSpec.configure do |config|
config.before(:each) do
# Clear the cache before each test
Rails.cache.clear
end
end

View file

@ -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: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
},
description: 'Map-related settings'
speed_color_scale: {
type: :string,
example: 'viridis',
description: 'Color scale for speed-colored routes'
},
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: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
},
description: 'Map-related settings'
speed_color_scale: {
type: :string,
example: 'viridis',
description: 'Color scale for speed-colored routes'
},
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'
}
}
}

View file

@ -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:
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
speed_color_scale:
type: string
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:
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
speed_color_scale:
type: string
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