mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #23629 from opf/fix/meeting-backlog-section
Expose the backlog section visibly through the meetings API, ensuring it appears
This commit is contained in:
@@ -23,6 +23,10 @@ properties:
|
|||||||
position:
|
position:
|
||||||
type: integer
|
type: integer
|
||||||
description: The position of the section within the meeting.
|
description: The position of the section within the meeting.
|
||||||
|
backlog:
|
||||||
|
type: boolean
|
||||||
|
description: |-
|
||||||
|
Whether this section is the meeting's backlog. Backlog sections are read-only via the API.
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ get:
|
|||||||
operationId: get_meeting_section
|
operationId: get_meeting_section
|
||||||
tags:
|
tags:
|
||||||
- Meetings
|
- Meetings
|
||||||
description: Retrieve an individual meeting section.
|
description: |-
|
||||||
|
Retrieve an individual meeting section.
|
||||||
|
|
||||||
|
The backlog section can be retrieved via this endpoint even though it is not part of the regular
|
||||||
|
`meeting.sections` association. Its `_links.meeting` points to the owning meeting (the series
|
||||||
|
template for recurring meeting occurrences).
|
||||||
parameters:
|
parameters:
|
||||||
- description: Section identifier
|
- description: Section identifier
|
||||||
example: 1
|
example: 1
|
||||||
@@ -34,7 +39,10 @@ patch:
|
|||||||
operationId: update_meeting_section
|
operationId: update_meeting_section
|
||||||
tags:
|
tags:
|
||||||
- Meetings
|
- Meetings
|
||||||
description: Updates the given meeting section.
|
description: |-
|
||||||
|
Updates the given meeting section.
|
||||||
|
|
||||||
|
The backlog section cannot be updated and will return `422 Unprocessable Entity`.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Section identifier
|
- description: Section identifier
|
||||||
example: 1
|
example: 1
|
||||||
@@ -88,7 +96,10 @@ delete:
|
|||||||
operationId: delete_meeting_section
|
operationId: delete_meeting_section
|
||||||
tags:
|
tags:
|
||||||
- Meetings
|
- Meetings
|
||||||
description: Deletes the meeting section.
|
description: |-
|
||||||
|
Deletes the meeting section and all its agenda items.
|
||||||
|
|
||||||
|
The backlog section cannot be deleted and will return `422 Unprocessable Entity`.
|
||||||
parameters:
|
parameters:
|
||||||
- description: Section identifier
|
- description: Section identifier
|
||||||
example: 1
|
example: 1
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ get:
|
|||||||
operationId: get_meeting_section_by_meeting
|
operationId: get_meeting_section_by_meeting
|
||||||
tags:
|
tags:
|
||||||
- Meetings
|
- Meetings
|
||||||
description: Retrieve an individual section of a meeting.
|
description: |-
|
||||||
|
Retrieve an individual section of a meeting.
|
||||||
|
|
||||||
|
The backlog section can be retrieved via this nested endpoint even though it is not part of the
|
||||||
|
regular `meeting.sections` association. Its `_links.self` and `_links.meeting` point to the
|
||||||
|
canonical flat section resource and the owning meeting (the series template for recurring
|
||||||
|
meeting occurrences).
|
||||||
parameters:
|
parameters:
|
||||||
- description: Meeting identifier
|
- description: Meeting identifier
|
||||||
example: 1
|
example: 1
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ get:
|
|||||||
operationId: list_meeting_sections
|
operationId: list_meeting_sections
|
||||||
tags:
|
tags:
|
||||||
- Meetings
|
- Meetings
|
||||||
description: Lists all sections for the given meeting.
|
description: |-
|
||||||
|
Lists all sections for the given meeting, including the backlog section as the last element.
|
||||||
|
|
||||||
|
The backlog section is read-only and its `_links.meeting` points to the owning meeting (the
|
||||||
|
series template for recurring meeting occurrences, otherwise the meeting itself).
|
||||||
parameters:
|
parameters:
|
||||||
- description: Meeting identifier
|
- description: Meeting identifier
|
||||||
example: 1
|
example: 1
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ module API
|
|||||||
getter:,
|
getter:,
|
||||||
setter:,
|
setter:,
|
||||||
if: show_if,
|
if: show_if,
|
||||||
skip_render: ->(*) { !embed_links || (skip_render && instance_exec(&skip_render)) },
|
skip_render: ->(*) { !embed_link?(name) || (skip_render && instance_exec(&skip_render)) },
|
||||||
linked_resource: true,
|
linked_resource: true,
|
||||||
embedded:,
|
embedded:,
|
||||||
uncacheable: true
|
uncacheable: true
|
||||||
@@ -142,7 +142,7 @@ module API
|
|||||||
getter:,
|
getter:,
|
||||||
setter:,
|
setter:,
|
||||||
if: show_if,
|
if: show_if,
|
||||||
skip_render: ->(*) { !embed_links || (skip_render && instance_exec(&skip_render)) },
|
skip_render: ->(*) { !embed_link?(name) || (skip_render && instance_exec(&skip_render)) },
|
||||||
linked_resource: true,
|
linked_resource: true,
|
||||||
embedded:,
|
embedded:,
|
||||||
uncacheable: true
|
uncacheable: true
|
||||||
@@ -177,7 +177,7 @@ module API
|
|||||||
link_getter: :"#{name}_id",
|
link_getter: :"#{name}_id",
|
||||||
link_property_name: nil,
|
link_property_name: nil,
|
||||||
uncacheable_link: false,
|
uncacheable_link: false,
|
||||||
getter: associated_resource_default_getter(name, representer),
|
getter: associated_resource_default_getter(name, representer, as || name),
|
||||||
setter: associated_resource_default_setter(name, as, v3_path),
|
setter: associated_resource_default_setter(name, as, v3_path),
|
||||||
link: associated_resource_default_link_lambda(link_property_name || name,
|
link: associated_resource_default_link_lambda(link_property_name || name,
|
||||||
v3_path:,
|
v3_path:,
|
||||||
@@ -243,11 +243,12 @@ module API
|
|||||||
end
|
end
|
||||||
|
|
||||||
def associated_resource_default_getter(name,
|
def associated_resource_default_getter(name,
|
||||||
representer)
|
representer,
|
||||||
|
embed_name)
|
||||||
representer ||= default_representer(name)
|
representer ||= default_representer(name)
|
||||||
|
|
||||||
->(*) do
|
->(*) do
|
||||||
if embed_links && represented.send(name)
|
if embed_link?(embed_name) && represented.send(name)
|
||||||
representer.create(represented.send(name), current_user:)
|
representer.create(represented.send(name), current_user:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -290,7 +291,7 @@ module API
|
|||||||
skip_link: skip_render,
|
skip_link: skip_render,
|
||||||
link_title_attribute: :name,
|
link_title_attribute: :name,
|
||||||
uncacheable_link: false,
|
uncacheable_link: false,
|
||||||
getter: associated_resources_default_getter(name, representer),
|
getter: associated_resources_default_getter(name, representer, as),
|
||||||
setter: associated_resources_default_setter(name, v3_path),
|
setter: associated_resources_default_setter(name, v3_path),
|
||||||
link: associated_resources_default_link(name,
|
link: associated_resources_default_link(name,
|
||||||
v3_path:,
|
v3_path:,
|
||||||
@@ -305,10 +306,13 @@ module API
|
|||||||
end
|
end
|
||||||
|
|
||||||
def associated_resources_default_getter(name,
|
def associated_resources_default_getter(name,
|
||||||
representer)
|
representer,
|
||||||
|
embed_name)
|
||||||
representer ||= default_representer(name.to_s.singularize)
|
representer ||= default_representer(name.to_s.singularize)
|
||||||
|
|
||||||
->(*) do
|
->(*) do
|
||||||
|
next unless embed_link?(embed_name)
|
||||||
|
|
||||||
represented.send(name)&.map do |associated|
|
represented.send(name)&.map do |associated|
|
||||||
representer.create(associated, current_user:)
|
representer.create(associated, current_user:)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ module API
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def embed_link?(name)
|
||||||
|
case embed_links
|
||||||
|
when true
|
||||||
|
true
|
||||||
|
when false, nil
|
||||||
|
false
|
||||||
|
else
|
||||||
|
embed_links.include?(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# If a subclass does not depend on a model being passed to this class, it can override
|
# If a subclass does not depend on a model being passed to this class, it can override
|
||||||
# this method and return false. Otherwise it will be enforced that the model of each
|
# this method and return false. Otherwise it will be enforced that the model of each
|
||||||
# representer is non-nil.
|
# representer is non-nil.
|
||||||
|
|||||||
@@ -32,5 +32,11 @@ module MeetingSections
|
|||||||
include EditableItem
|
include EditableItem
|
||||||
|
|
||||||
delete_permission :manage_agendas
|
delete_permission :manage_agendas
|
||||||
|
|
||||||
|
validate :backlog_not_deletable
|
||||||
|
|
||||||
|
def backlog_not_deletable
|
||||||
|
errors.add :base, :error_readonly if model.backlog?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
module MeetingSections
|
module MeetingSections
|
||||||
class UpdateContract < BaseContract
|
class UpdateContract < BaseContract
|
||||||
validate :user_allowed_to_edit
|
validate :user_allowed_to_edit
|
||||||
|
validate :backlog_not_editable
|
||||||
|
|
||||||
# We allow an empty title internally via create to mark an untitled/implicit section
|
# We allow an empty title internally via create to mark an untitled/implicit section
|
||||||
# but users should not be able to update it with an empty title through this contract
|
# but users should not be able to update it with an empty title through this contract
|
||||||
@@ -44,5 +45,9 @@ module MeetingSections
|
|||||||
errors.add :base, :error_unauthorized
|
errors.add :base, :error_unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def backlog_not_editable
|
||||||
|
errors.add :base, :error_readonly if model.backlog?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-5
@@ -32,14 +32,12 @@ module API
|
|||||||
module V3
|
module V3
|
||||||
module MeetingAgendaItems
|
module MeetingAgendaItems
|
||||||
class MeetingAgendaItemCollectionRepresenter < ::API::Decorators::UnpaginatedCollection
|
class MeetingAgendaItemCollectionRepresenter < ::API::Decorators::UnpaginatedCollection
|
||||||
# Force `embed_links` on the elements so that their associated resources
|
# Embed outcomes and sections by default without repeating already known
|
||||||
# (most notably the outcomes) are embedded by default, mirroring the
|
# resources such as the parent meeting.
|
||||||
# single agenda item endpoint. All of these associations are already
|
|
||||||
# eager loaded by the element representer, so this adds no extra queries.
|
|
||||||
collection :elements,
|
collection :elements,
|
||||||
getter: ->(*) {
|
getter: ->(*) {
|
||||||
represented.map do |model|
|
represented.map do |model|
|
||||||
element_decorator.create(model, current_user:, embed_links: true)
|
element_decorator.create(model, current_user:, embed_links: %i[section outcomes])
|
||||||
end
|
end
|
||||||
},
|
},
|
||||||
exec_context: :decorator,
|
exec_context: :decorator,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ module API
|
|||||||
|
|
||||||
property :position
|
property :position
|
||||||
|
|
||||||
|
property :backlog
|
||||||
|
|
||||||
associated_resource :meeting,
|
associated_resource :meeting,
|
||||||
link: ->(*) {
|
link: ->(*) {
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,9 +32,17 @@ module API
|
|||||||
module V3
|
module V3
|
||||||
module MeetingSections
|
module MeetingSections
|
||||||
class SectionsByMeetingAPI < ::API::OpenProjectAPI
|
class SectionsByMeetingAPI < ::API::OpenProjectAPI
|
||||||
|
helpers do
|
||||||
|
def find_backlog_section(id)
|
||||||
|
backlog = @meeting.backlog
|
||||||
|
backlog if backlog&.id == id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :sections do
|
resources :sections do
|
||||||
get do
|
get do
|
||||||
sections = @meeting.sections
|
sections = @meeting.sections.to_a
|
||||||
|
sections << @meeting.backlog if @meeting.backlog.present?
|
||||||
MeetingSectionCollectionRepresenter.new(sections,
|
MeetingSectionCollectionRepresenter.new(sections,
|
||||||
self_link: api_v3_paths.meeting_sections(meeting_id: @meeting.id),
|
self_link: api_v3_paths.meeting_sections(meeting_id: @meeting.id),
|
||||||
current_user:)
|
current_user:)
|
||||||
@@ -42,7 +50,9 @@ module API
|
|||||||
|
|
||||||
route_param :section_id, type: Integer, desc: "Section ID" do
|
route_param :section_id, type: Integer, desc: "Section ID" do
|
||||||
after_validation do
|
after_validation do
|
||||||
@meeting_section = @meeting.sections.find(declared_params[:section_id])
|
@meeting_section = @meeting.sections.find_by(id: declared_params[:section_id]) ||
|
||||||
|
find_backlog_section(declared_params[:section_id])
|
||||||
|
raise API::Errors::NotFound.new unless @meeting_section
|
||||||
end
|
end
|
||||||
|
|
||||||
get &::API::V3::Utilities::Endpoints::Show.new(model: MeetingSection).mount
|
get &::API::V3::Utilities::Endpoints::Show.new(model: MeetingSection).mount
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ RSpec.describe MeetingSections::DeleteContract do
|
|||||||
|
|
||||||
it_behaves_like "contract is valid"
|
it_behaves_like "contract is valid"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when the section is the backlog" do
|
||||||
|
let(:section) { meeting.backlog }
|
||||||
|
|
||||||
|
it_behaves_like "contract is invalid", base: :error_readonly
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "without permission" do
|
context "without permission" do
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ RSpec.describe MeetingSections::UpdateContract do
|
|||||||
|
|
||||||
it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore)
|
it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when the section is the backlog" do
|
||||||
|
let(:section) { meeting.backlog }
|
||||||
|
|
||||||
|
it_behaves_like "contract is invalid", base: :error_readonly
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "without permission" do
|
context "without permission" do
|
||||||
|
|||||||
+94
@@ -83,6 +83,64 @@ RSpec.describe "API v3 Meeting Agenda Items sub-resource", content_type: :json d
|
|||||||
.at_path("_embedded/elements/0/_links/section/href")
|
.at_path("_embedded/elements/0/_links/section/href")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "only embeds outcomes and sections for the agenda items" do
|
||||||
|
expect(last_response.body)
|
||||||
|
.to have_json_path("_embedded/elements/0/_embedded/outcomes")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(section.id.to_json)
|
||||||
|
.at_path("_embedded/elements/0/_embedded/section/id")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.not_to have_json_path("_embedded/elements/0/_embedded/meeting")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.not_to have_json_path("_embedded/elements/0/_embedded/author")
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with an agenda item in the meeting backlog" do
|
||||||
|
let(:backlog) { meeting.backlog }
|
||||||
|
let!(:backlog_agenda_item) do
|
||||||
|
create(:meeting_agenda_item,
|
||||||
|
meeting:,
|
||||||
|
meeting_section: backlog,
|
||||||
|
author: current_user,
|
||||||
|
title: "Backlog agenda item")
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
get path
|
||||||
|
end
|
||||||
|
|
||||||
|
it "embeds and links the backlog section" do
|
||||||
|
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
|
||||||
|
element = elements.find { |item| item["id"] == backlog_agenda_item.id }
|
||||||
|
embedded_section = element.dig("_embedded", "section")
|
||||||
|
|
||||||
|
expect(element.dig("_links", "section", "href"))
|
||||||
|
.to eq(api_v3_paths.meeting_section(backlog.id))
|
||||||
|
expect(element.dig("_links", "section", "title")).to eq(backlog.title)
|
||||||
|
expect(embedded_section["id"]).to eq(backlog.id)
|
||||||
|
expect(embedded_section["backlog"]).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "uses a retrievable backlog section link with canonical meeting ownership" do
|
||||||
|
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
|
||||||
|
backlog_section_href = elements
|
||||||
|
.find { |item| item["id"] == backlog_agenda_item.id }
|
||||||
|
.dig("_links", "section", "href")
|
||||||
|
|
||||||
|
get backlog_section_href
|
||||||
|
|
||||||
|
expect(last_response).to have_http_status(:ok)
|
||||||
|
expect(last_response.body).to be_json_eql(backlog.id.to_json).at_path("id")
|
||||||
|
expect(last_response.body).to be_json_eql(true.to_json).at_path("backlog")
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting(meeting.id).to_json)
|
||||||
|
.at_path("_links/meeting/href")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "without view_meetings permission" do
|
context "without view_meetings permission" do
|
||||||
let(:permissions) { [] }
|
let(:permissions) { [] }
|
||||||
|
|
||||||
@@ -203,6 +261,42 @@ RSpec.describe "API v3 Meeting Agenda Items sub-resource", content_type: :json d
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when the agenda item is part of the backlog" do
|
||||||
|
let(:backlog) { meeting.backlog }
|
||||||
|
let!(:backlog_agenda_item) do
|
||||||
|
create(:meeting_agenda_item,
|
||||||
|
meeting:,
|
||||||
|
meeting_section: backlog,
|
||||||
|
author: current_user,
|
||||||
|
title: "Backlog agenda item")
|
||||||
|
end
|
||||||
|
let(:path) { api_v3_paths.meeting_agenda_item(backlog_agenda_item.id, meeting_id: meeting.id) }
|
||||||
|
|
||||||
|
it "renders the backlog section correctly" do
|
||||||
|
expect(last_response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
|
||||||
|
.at_path("_links/section/href")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(backlog.title.to_json)
|
||||||
|
.at_path("_links/section/title")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(backlog.id.to_json)
|
||||||
|
.at_path("_embedded/section/id")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(true.to_json)
|
||||||
|
.at_path("_embedded/section/backlog")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting(meeting.id).to_json)
|
||||||
|
.at_path("_embedded/section/_links/meeting/href")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "when the agenda item is linked to a work package in an inaccessible project" do
|
context "when the agenda item is linked to a work package in an inaccessible project" do
|
||||||
let(:private_project) { create(:project, public: false) }
|
let(:private_project) { create(:project, public: false) }
|
||||||
let(:private_work_package) { create(:work_package, project: private_project) }
|
let(:private_work_package) { create(:work_package, project: private_project) }
|
||||||
|
|||||||
+132
@@ -250,6 +250,138 @@ RSpec.describe "API v3 Meeting Sections sub-resource", content_type: :json do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "backlog section" do
|
||||||
|
let(:backlog) { meeting.backlog }
|
||||||
|
|
||||||
|
describe "GET /api/v3/meetings/:meeting_id/sections" do
|
||||||
|
let(:path) { api_v3_paths.meeting_sections(meeting_id: meeting.id) }
|
||||||
|
|
||||||
|
before { get path }
|
||||||
|
|
||||||
|
it "includes the backlog as the last section" do
|
||||||
|
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
|
||||||
|
|
||||||
|
expect(elements.pluck("id")).to eq([section.id, backlog.id])
|
||||||
|
expect(elements.last).to include("backlog" => true)
|
||||||
|
expect(elements.first).to include("backlog" => false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/v3/meetings/:meeting_id/sections/:id" do
|
||||||
|
let(:path) { api_v3_paths.meeting_section(backlog.id, meeting_id: meeting.id) }
|
||||||
|
|
||||||
|
before { get path }
|
||||||
|
|
||||||
|
it "returns the backlog with canonical links" do
|
||||||
|
expect(last_response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(backlog.id.to_json)
|
||||||
|
.at_path("id")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(true.to_json)
|
||||||
|
.at_path("backlog")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
|
||||||
|
.at_path("_links/self/href")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting(meeting.id).to_json)
|
||||||
|
.at_path("_links/meeting/href")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v3/meeting_sections/:id" do
|
||||||
|
let(:path) { api_v3_paths.meeting_section(backlog.id) }
|
||||||
|
let(:original_title) { backlog.title }
|
||||||
|
let(:body) { { title: "Updated Backlog Title" }.to_json }
|
||||||
|
|
||||||
|
subject(:response) { patch path, body }
|
||||||
|
|
||||||
|
it "responds with 422 and does not change the title" do
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(backlog.reload.title).to eq(original_title)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE /api/v3/meeting_sections/:id" do
|
||||||
|
let(:path) { api_v3_paths.meeting_section(backlog.id) }
|
||||||
|
|
||||||
|
before { delete path }
|
||||||
|
|
||||||
|
it "responds with 422 and keeps the backlog" do
|
||||||
|
expect(last_response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(MeetingSection).to exist(backlog.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "backlog section for a recurring meeting occurrence" do
|
||||||
|
let(:recurring_meeting) { create(:recurring_meeting, project:, author: current_user) }
|
||||||
|
let(:template) { recurring_meeting.template }
|
||||||
|
let(:occurrence) do
|
||||||
|
create(:recurring_meeting_occurrence, recurring_meeting:, project:, author: current_user)
|
||||||
|
end
|
||||||
|
let!(:occurrence_section) { create(:meeting_section, meeting: occurrence, title: "Occurrence Section") }
|
||||||
|
let(:backlog) { template.backlog }
|
||||||
|
|
||||||
|
describe "GET /api/v3/meetings/:meeting_id/sections" do
|
||||||
|
let(:path) { api_v3_paths.meeting_sections(meeting_id: occurrence.id) }
|
||||||
|
|
||||||
|
before { get path }
|
||||||
|
|
||||||
|
it "includes the backlog with canonical links to the template meeting" do
|
||||||
|
elements = JSON.parse(last_response.body).dig("_embedded", "elements")
|
||||||
|
backlog_element = elements.find { |element| element["id"] == backlog.id }
|
||||||
|
|
||||||
|
expect(elements.pluck("id")).to eq([occurrence_section.id, backlog.id])
|
||||||
|
expect(backlog_element).to include("backlog" => true)
|
||||||
|
expect(backlog_element.dig("_links", "self", "href"))
|
||||||
|
.to eq(api_v3_paths.meeting_section(backlog.id))
|
||||||
|
expect(backlog_element.dig("_links", "meeting", "href"))
|
||||||
|
.to eq(api_v3_paths.meeting(template.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/v3/meetings/:meeting_id/sections/:id" do
|
||||||
|
let(:path) { api_v3_paths.meeting_section(backlog.id, meeting_id: occurrence.id) }
|
||||||
|
|
||||||
|
before { get path }
|
||||||
|
|
||||||
|
it "returns the backlog with canonical links to the template meeting" do
|
||||||
|
expect(last_response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting_section(backlog.id).to_json)
|
||||||
|
.at_path("_links/self/href")
|
||||||
|
|
||||||
|
expect(last_response.body)
|
||||||
|
.to be_json_eql(api_v3_paths.meeting(template.id).to_json)
|
||||||
|
.at_path("_links/meeting/href")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "agenda item section link consistency" do
|
||||||
|
let!(:agenda_item) do
|
||||||
|
create(:meeting_agenda_item,
|
||||||
|
meeting: occurrence,
|
||||||
|
meeting_section: backlog,
|
||||||
|
author: current_user,
|
||||||
|
title: "Backlog item")
|
||||||
|
end
|
||||||
|
let(:path) { api_v3_paths.meeting_section(backlog.id) }
|
||||||
|
|
||||||
|
before { get path }
|
||||||
|
|
||||||
|
it "resolves the section referenced by the agenda item" do
|
||||||
|
expect(last_response).to have_http_status(:ok)
|
||||||
|
expect(last_response.body).to be_json_eql(backlog.id.to_json).at_path("id")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "DELETE /api/v3/meeting_sections/:id" do
|
describe "DELETE /api/v3/meeting_sections/:id" do
|
||||||
let(:path) { api_v3_paths.meeting_section(section.id) }
|
let(:path) { api_v3_paths.meeting_section(section.id) }
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,112 @@ RSpec.describe API::Decorators::LinkedResource do
|
|||||||
let(:represented) { {} }
|
let(:represented) { {} }
|
||||||
let(:current_user) { create(:user) }
|
let(:current_user) { create(:user) }
|
||||||
|
|
||||||
|
describe "embedded links" do
|
||||||
|
let(:thing_representer_class) do
|
||||||
|
Class.new(API::Decorators::Single) do
|
||||||
|
property :id
|
||||||
|
def _type = "Thing"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:representer_class) do
|
||||||
|
klass = thing_representer_class
|
||||||
|
Class.new(API::Decorators::Single) do
|
||||||
|
include API::Decorators::LinkedResource
|
||||||
|
|
||||||
|
associated_resource :included_thing, v3_path: :user, representer: klass
|
||||||
|
associated_resource :excluded_thing, v3_path: :user, representer: klass
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:included_thing) { Struct.new(:id, :name).new(1, "Included thing") }
|
||||||
|
let(:excluded_thing) { Struct.new(:id, :name).new(2, "Excluded thing") }
|
||||||
|
let(:model) do
|
||||||
|
Struct
|
||||||
|
.new(:included_thing_id, :included_thing, :excluded_thing_id, :excluded_thing)
|
||||||
|
.new(included_thing.id, included_thing, excluded_thing.id, excluded_thing)
|
||||||
|
end
|
||||||
|
|
||||||
|
subject(:json) do
|
||||||
|
representer_class
|
||||||
|
.new(model, current_user:, embed_links: %i[included_thing])
|
||||||
|
.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
it "embeds only the selected links" do
|
||||||
|
expect(json).to have_json_path("_embedded/includedThing")
|
||||||
|
expect(json).not_to have_json_path("_embedded/excludedThing")
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when an excluded embedded resource has no readable association" do
|
||||||
|
let(:representer_class) do
|
||||||
|
klass = thing_representer_class
|
||||||
|
Class.new(API::Decorators::Single) do
|
||||||
|
include API::Decorators::LinkedResource
|
||||||
|
|
||||||
|
associated_resource :included_thing, v3_path: :user, representer: klass
|
||||||
|
associated_resource :excluded_thing,
|
||||||
|
v3_path: :user,
|
||||||
|
representer: klass,
|
||||||
|
link: ->(*) {
|
||||||
|
{
|
||||||
|
href: "/api/v3/users/#{represented.excluded_thing_id}",
|
||||||
|
title: "Excluded thing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:model) do
|
||||||
|
Class.new do
|
||||||
|
attr_reader :included_thing_id, :included_thing, :excluded_thing_id
|
||||||
|
|
||||||
|
def initialize(included_thing, excluded_thing)
|
||||||
|
@included_thing_id = included_thing.id
|
||||||
|
@included_thing = included_thing
|
||||||
|
@excluded_thing_id = excluded_thing.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def excluded_thing
|
||||||
|
raise "excluded association should not be read"
|
||||||
|
end
|
||||||
|
end.new(included_thing, excluded_thing)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not call the excluded association getter" do
|
||||||
|
expect { json }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with plural associated resources" do
|
||||||
|
let(:representer_class) do
|
||||||
|
klass = thing_representer_class
|
||||||
|
Class.new(API::Decorators::Single) do
|
||||||
|
include API::Decorators::LinkedResource
|
||||||
|
|
||||||
|
associated_resources :included_things, v3_path: :user, representer: klass
|
||||||
|
associated_resources :excluded_things, v3_path: :user, representer: klass
|
||||||
|
end
|
||||||
|
end
|
||||||
|
let(:model) do
|
||||||
|
Struct
|
||||||
|
.new(:included_things, :excluded_things)
|
||||||
|
.new([included_thing], [excluded_thing])
|
||||||
|
end
|
||||||
|
|
||||||
|
subject(:json) do
|
||||||
|
representer_class
|
||||||
|
.new(model, current_user:, embed_links: %i[included_things])
|
||||||
|
.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
it "embeds only the selected collection links" do
|
||||||
|
expect(json).to have_json_path("_embedded/includedThings")
|
||||||
|
expect(json).not_to have_json_path("_embedded/excludedThings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe ".associated_visible_resource" do
|
describe ".associated_visible_resource" do
|
||||||
include API::V3::Utilities::PathHelper
|
include API::V3::Utilities::PathHelper
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user