2026-05-08 10:10:25 +02:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
|
|
#-- copyright
|
|
|
|
|
# OpenProject is an open source project management software.
|
|
|
|
|
# Copyright (C) the OpenProject GmbH
|
|
|
|
|
#
|
|
|
|
|
# This program is free software; you can redistribute it and/or
|
|
|
|
|
# modify it under the terms of the GNU General Public License version 3.
|
|
|
|
|
#
|
|
|
|
|
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
|
|
|
|
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
|
|
|
|
# Copyright (C) 2010-2013 the ChiliProject Team
|
|
|
|
|
#
|
|
|
|
|
# This program is free software; you can redistribute it and/or
|
|
|
|
|
# modify it under the terms of the GNU General Public License
|
|
|
|
|
# as published by the Free Software Foundation; either version 2
|
|
|
|
|
# of the License, or (at your option) any later version.
|
|
|
|
|
#
|
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
# GNU General Public License for more details.
|
|
|
|
|
#
|
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
|
# along with this program; if not, write to the Free Software
|
|
|
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
|
#
|
|
|
|
|
# See COPYRIGHT and LICENSE files for more details.
|
|
|
|
|
#++
|
|
|
|
|
|
|
|
|
|
require "spec_helper"
|
|
|
|
|
|
|
|
|
|
# Integration coverage for UserQuery with real users, real custom fields and
|
|
|
|
|
# real persistence. Unit-level expectations live in user_query_spec.rb;
|
|
|
|
|
# this spec exercises the full filter→SQL→ActiveRecord round trip.
|
|
|
|
|
RSpec.describe UserQuery, "integration" do
|
|
|
|
|
shared_let(:job_title_cf) do
|
|
|
|
|
create(:user_custom_field, :list,
|
|
|
|
|
name: "Job title",
|
|
|
|
|
possible_values: ["Developer", "Designer", "Project Manager", "Product Manager"])
|
|
|
|
|
end
|
|
|
|
|
shared_let(:nickname_cf) do
|
|
|
|
|
create(:user_custom_field, :string, name: "Nickname")
|
|
|
|
|
end
|
2026-05-08 10:27:15 +02:00
|
|
|
shared_let(:birthday_cf) do
|
|
|
|
|
create(:user_custom_field, :date, name: "Birthday")
|
|
|
|
|
end
|
2026-05-08 10:10:25 +02:00
|
|
|
|
|
|
|
|
shared_let(:developer_option) { job_title_cf.custom_options.find_by(value: "Developer") }
|
|
|
|
|
shared_let(:designer_option) { job_title_cf.custom_options.find_by(value: "Designer") }
|
|
|
|
|
shared_let(:pm_option) { job_title_cf.custom_options.find_by(value: "Project Manager") }
|
|
|
|
|
|
2026-05-13 06:53:46 +02:00
|
|
|
# Alice is the logged-in user across most examples; she needs view_all_principals
|
|
|
|
|
# so the existing filter/order expectations enumerate every seeded user.
|
|
|
|
|
# Visibility behaviour is exercised separately in the `describe "visibility"` block.
|
|
|
|
|
shared_let(:alice) { create(:user, firstname: "Alice", lastname: "Anders", global_permissions: %i[view_all_principals]) }
|
2026-05-08 10:10:25 +02:00
|
|
|
shared_let(:bob) { create(:user, firstname: "Bob", lastname: "Bauer") }
|
|
|
|
|
shared_let(:carol) { create(:user, firstname: "Carol", lastname: "Cohen") }
|
|
|
|
|
shared_let(:dave) { create(:user, firstname: "Dave", lastname: "Doe") }
|
|
|
|
|
shared_let(:locked_eve) { create(:user, firstname: "Eve", lastname: "Eriksson", status: :locked) }
|
|
|
|
|
|
|
|
|
|
before_all do
|
2026-05-08 10:27:15 +02:00
|
|
|
[[alice, developer_option, "ace", "1990-04-12"],
|
|
|
|
|
[bob, developer_option, "bobster", "1985-09-23"],
|
|
|
|
|
[carol, designer_option, "carol-bear", "1995-02-14"],
|
|
|
|
|
[dave, pm_option, nil, "2001-07-08"],
|
|
|
|
|
[locked_eve, developer_option, "evil-eve", "1980-11-30"]].each do |user, option, nickname, birthday|
|
|
|
|
|
values = { job_title_cf.id => option.id, birthday_cf.id => birthday }
|
2026-05-08 10:10:25 +02:00
|
|
|
values[nickname_cf.id] = nickname if nickname
|
|
|
|
|
user.custom_field_values = values
|
|
|
|
|
user.save!(validate: false)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
before { login_as(alice) }
|
|
|
|
|
|
|
|
|
|
let(:query) { described_class.new(name: "Users") }
|
|
|
|
|
|
|
|
|
|
describe "filtering by a list custom field" do
|
|
|
|
|
it "returns only users whose CF value matches the selected option" do
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice, bob, locked_eve)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "supports the negated operator (excludes the matching option, includes users with no value)" do
|
|
|
|
|
query.where(job_title_cf.column_name, "!", [developer_option.id.to_s])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(carol, dave)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "supports 'all and non-blank' to find users with any value set" do
|
|
|
|
|
query.where(job_title_cf.column_name, "*", [])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice, bob, carol, dave, locked_eve)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "supports 'none or blank' to find users with no value set" do
|
|
|
|
|
# All seeded users have a job title, so a fresh user with no CF data should be the only match.
|
|
|
|
|
blank_user = create(:user, firstname: "Frank", lastname: "Frost")
|
|
|
|
|
|
|
|
|
|
query.where(job_title_cf.column_name, "!*", [])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(blank_user)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
describe "filtering by a string custom field" do
|
|
|
|
|
it "supports the contains (~) operator" do
|
|
|
|
|
query.where(nickname_cf.column_name, "~", ["bear"])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(carol)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:27:15 +02:00
|
|
|
describe "filtering by a date custom field" do
|
|
|
|
|
# The date filter uses "<>d" (between dates). Leaving the upper bound empty
|
|
|
|
|
# gives "on or after <date>" semantics — i.e. the "greater than or equal" case.
|
|
|
|
|
it "returns users with a birthday on or after a cutoff date" do
|
|
|
|
|
query.where(birthday_cf.column_name, "<>d", ["1990-01-01", ""])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice, carol, dave)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "returns users with a birthday on or before a cutoff date" do
|
|
|
|
|
query.where(birthday_cf.column_name, "<>d", ["", "1989-12-31"])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(bob, locked_eve)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "returns users with a birthday inside a closed interval" do
|
|
|
|
|
query.where(birthday_cf.column_name, "<>d", ["1985-01-01", "1995-12-31"])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice, bob, carol)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:10:25 +02:00
|
|
|
describe "combining a CF filter with built-in filters" do
|
|
|
|
|
it "applies status and CF filters with AND semantics" do
|
|
|
|
|
query.where("status", "=", ["active"])
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice, bob)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "applies a name filter together with a CF filter" do
|
|
|
|
|
query.where("name", "~", ["alice"])
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(alice)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
describe "ordering does not interfere with CF filtering" do
|
|
|
|
|
it "returns matching users in the requested order" do
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
query.order(id: :asc)
|
|
|
|
|
|
|
|
|
|
expect(query.results.to_a).to eq([alice, bob, locked_eve].sort_by(&:id))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:27:15 +02:00
|
|
|
describe "ordering by a custom field" do
|
|
|
|
|
it "sorts users by a string CF value asc, with users without a value first" do
|
|
|
|
|
query.order(nickname_cf.column_name => :asc)
|
|
|
|
|
|
|
|
|
|
ordered = query.results.to_a
|
|
|
|
|
|
|
|
|
|
# dave has no nickname → NULLS FIRST on asc
|
|
|
|
|
expect(ordered.first).to eq(dave)
|
|
|
|
|
# remaining users sorted by their nickname value: ace, bobster, carol-bear, evil-eve
|
|
|
|
|
expect(ordered.drop(1)).to eq([alice, bob, carol, locked_eve])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "sorts users by a list CF value (option position)" do
|
|
|
|
|
query.order(job_title_cf.column_name => :asc)
|
|
|
|
|
|
|
|
|
|
grouped = query.results.to_a.group_by { |u| u.custom_value_for(job_title_cf)&.value&.to_i }
|
|
|
|
|
developer_users = grouped[developer_option.id]
|
|
|
|
|
designer_users = grouped[designer_option.id]
|
|
|
|
|
pm_users = grouped[pm_option.id]
|
|
|
|
|
|
|
|
|
|
expect(developer_users).to contain_exactly(alice, bob, locked_eve)
|
|
|
|
|
expect(designer_users).to eq([carol])
|
|
|
|
|
expect(pm_users).to eq([dave])
|
|
|
|
|
|
|
|
|
|
# CustomOption position drives the order — the seeded `possible_values` order is
|
|
|
|
|
# Developer, Designer, Project Manager, Product Manager.
|
|
|
|
|
first_titles = query.results.to_a.map { |u| u.custom_value_for(job_title_cf)&.value&.to_i }
|
|
|
|
|
expect(first_titles.compact).to eq(first_titles.compact.sort_by do |id|
|
|
|
|
|
[developer_option.id, designer_option.id, pm_option.id].index(id) || Float::INFINITY
|
|
|
|
|
end)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "sorts users by a date CF asc (oldest birthday first)" do
|
|
|
|
|
query.order(birthday_cf.column_name => :asc)
|
|
|
|
|
|
|
|
|
|
# All five seeded users have a birthday set, so no NULLS-FIRST padding.
|
|
|
|
|
expect(query.results.to_a).to eq([locked_eve, bob, alice, carol, dave])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "sorts users by a date CF desc (newest birthday first)" do
|
|
|
|
|
query.order(birthday_cf.column_name => :desc)
|
|
|
|
|
|
|
|
|
|
expect(query.results.to_a).to eq([dave, carol, alice, bob, locked_eve])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "combines a date CF filter with a date CF order" do
|
|
|
|
|
query.where(birthday_cf.column_name, "<>d", ["1990-01-01", ""])
|
|
|
|
|
query.order(birthday_cf.column_name => :asc)
|
|
|
|
|
|
|
|
|
|
expect(query.results.to_a).to eq([alice, carol, dave])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "rejects ordering by a text CF (excluded from sortable formats)" do
|
|
|
|
|
text_cf = create(:user_custom_field, :text, name: "Bio")
|
|
|
|
|
|
|
|
|
|
query.order(text_cf.column_name => :asc)
|
|
|
|
|
|
|
|
|
|
expect(query).not_to be_valid
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
describe "selecting a custom field column" do
|
|
|
|
|
it "exposes a select for every visible UserCustomField" do
|
|
|
|
|
available = Queries::Users::Selects::CustomField.all_available.map(&:attribute)
|
|
|
|
|
|
|
|
|
|
expect(available).to include(:"cf_#{job_title_cf.id}", :"cf_#{nickname_cf.id}")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "constructs a valid select pointing at the right CustomField" do
|
|
|
|
|
select = Queries::Users::Selects::CustomField.new(:"cf_#{job_title_cf.id}")
|
|
|
|
|
|
|
|
|
|
expect(select).to be_available
|
|
|
|
|
expect(select.custom_field).to eq(job_title_cf)
|
|
|
|
|
expect(select.caption).to eq(job_title_cf.name)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-13 06:53:46 +02:00
|
|
|
describe "visibility" do
|
|
|
|
|
# Override the outer admin login so each context can pin down its own viewer.
|
|
|
|
|
let(:viewer) { create(:user, firstname: "Viewer", lastname: "Visible") }
|
|
|
|
|
|
|
|
|
|
before { login_as(viewer) }
|
|
|
|
|
|
|
|
|
|
context "when the current user is an admin" do
|
|
|
|
|
let(:viewer) { create(:admin, firstname: "Viewer", lastname: "Admin") }
|
|
|
|
|
|
|
|
|
|
it "returns every active user" do
|
|
|
|
|
expect(query.results).to include(alice, bob, carol, dave, locked_eve, viewer)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
context "when the current user has the :view_all_principals global permission" do
|
|
|
|
|
let(:viewer) do
|
|
|
|
|
create(:user, firstname: "Viewer", lastname: "Global",
|
|
|
|
|
global_permissions: %i[view_all_principals])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "returns every active user" do
|
|
|
|
|
expect(query.results).to include(alice, bob, carol, dave, locked_eve, viewer)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
context "when the current user has no view permission and no shared membership" do
|
|
|
|
|
it "only sees themselves" do
|
|
|
|
|
expect(query.results).to contain_exactly(viewer)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "sees other users that share a project membership" do
|
|
|
|
|
role = create(:project_role)
|
|
|
|
|
project = create(:project)
|
|
|
|
|
create(:member, principal: viewer, project: project, roles: [role])
|
|
|
|
|
create(:member, principal: alice, project: project, roles: [role])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(viewer, alice)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "sees other users that share a group membership" do
|
|
|
|
|
create(:group, members: [viewer, alice])
|
|
|
|
|
|
|
|
|
|
expect(query.results).to contain_exactly(viewer, alice)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "does not see users that share neither a project nor a group" do
|
|
|
|
|
expect(query.results).not_to include(bob, carol, dave, locked_eve)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:10:25 +02:00
|
|
|
describe "PersistedQuery round-trip" do
|
|
|
|
|
it "persists and re-runs a UserQuery with a CF filter" do
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
query.where("status", "=", ["active"])
|
|
|
|
|
query.order(name: :asc)
|
|
|
|
|
query.save!
|
|
|
|
|
|
|
|
|
|
reloaded = described_class.find(query.id)
|
|
|
|
|
|
|
|
|
|
expect(reloaded.filters.size).to eq(2)
|
|
|
|
|
cf_filter = reloaded.filters.detect { |f| f.is_a?(Queries::Filters::Shared::CustomFields::ListOptional) }
|
|
|
|
|
expect(cf_filter).not_to be_nil
|
|
|
|
|
expect(cf_filter.custom_field).to eq(job_title_cf)
|
|
|
|
|
expect(cf_filter.values).to eq([developer_option.id.to_s])
|
|
|
|
|
expect(reloaded.orders.first).to be_a(Queries::Users::Orders::NameOrder)
|
|
|
|
|
|
|
|
|
|
expect(reloaded.results).to contain_exactly(alice, bob)
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:27:15 +02:00
|
|
|
it "persists and re-runs a UserQuery with a CF order and a CF select" do
|
|
|
|
|
query.where("status", "=", ["active"])
|
|
|
|
|
query.order(job_title_cf.column_name => :asc)
|
|
|
|
|
query.select(:"cf_#{job_title_cf.id}")
|
|
|
|
|
query.save!
|
|
|
|
|
|
|
|
|
|
reloaded = described_class.find(query.id)
|
|
|
|
|
|
|
|
|
|
expect(reloaded.orders.first).to be_a(Queries::Users::Orders::CustomFieldOrder)
|
|
|
|
|
expect(reloaded.orders.first.custom_field).to eq(job_title_cf)
|
|
|
|
|
expect(reloaded.selects.first).to be_a(Queries::Users::Selects::CustomField)
|
|
|
|
|
expect(reloaded.selects.first.custom_field).to eq(job_title_cf)
|
|
|
|
|
|
|
|
|
|
# Result is still scoped by the persisted filter and ordered by the CF.
|
|
|
|
|
expect(reloaded.results.to_a).to include(alice, bob, carol, dave)
|
|
|
|
|
end
|
|
|
|
|
|
2026-05-08 10:10:25 +02:00
|
|
|
it "stores the CF filter as the cf_<id> attribute hash in the database" do
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
query.save!
|
|
|
|
|
|
|
|
|
|
raw_filters = PersistedQuery.connection.select_value(
|
|
|
|
|
"SELECT filters FROM persisted_queries WHERE id = #{query.id}"
|
|
|
|
|
)
|
|
|
|
|
raw_filters = JSON.parse(raw_filters) if raw_filters.is_a?(String)
|
|
|
|
|
|
|
|
|
|
expect(raw_filters).to eq([{
|
|
|
|
|
"attribute" => job_title_cf.column_name,
|
|
|
|
|
"operator" => "=",
|
|
|
|
|
"values" => [developer_option.id.to_s]
|
|
|
|
|
}])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
it "falls back to a NotExistingFilter when the referenced CF has been deleted" do
|
|
|
|
|
query.where(job_title_cf.column_name, "=", [developer_option.id.to_s])
|
|
|
|
|
query.save!
|
|
|
|
|
|
|
|
|
|
job_title_cf.destroy!
|
|
|
|
|
# The shared CF filter caches `UserCustomField.all` in RequestStore for the
|
|
|
|
|
# life of the request; clear it so the deserializer sees the deletion.
|
|
|
|
|
RequestStore.clear!
|
|
|
|
|
|
|
|
|
|
reloaded = described_class.find(query.id)
|
|
|
|
|
expect(reloaded.filters.first).to be_a(Queries::Filters::NotExistingFilter)
|
|
|
|
|
expect(reloaded).not_to be_valid
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|